Skip to content

fix(mcp): smem_edit recomputes expires_at when the type changes, without reviving tombstones or ephemerals - #214

Merged
acidkill merged 1 commit into
acidkill:mainfrom
RobertSigmundsson:fix/smem-edit-type-change-does-not-recompute-ttl
Sep 7, 2026
Merged

acidkill merged 1 commit into
acidkill:mainfrom
RobertSigmundsson:fix/smem-edit-type-change-does-not-recompute-ttl

Conversation

@RobertSigmundsson

Copy link
Copy Markdown
Contributor

Summary

  • smem_edit now recomputes expires_at from the new type's default when the memory type changes, instead of leaving the old type's TTL in place.
  • The recompute is skipped for soft-deleted and ephemeral memories, both of which carry an expiry that means something and must survive a type change.
  • Adds four tests: one per direction of the recompute, one per guard.

Why

_edit's type-change branch swapped memory_type and left the old TTL untouched. DEFAULT_EXPIRY_DAYS is consulted only at creation time, through remember_handler's expires_in_days, and TypedMemory is a frozen dataclass with no __post_init__, so nothing recomputed it afterwards either.

The result was wrong in both directions. A DECISION (90 days by default) edited to FACT (no default expiry) still expired roughly ninety days out, so a fact quietly vanished on the decision's clock. Going the other way, a FACT edited to TODO or ERROR (30 days each) never acquired a finite expiry and persisted indefinitely.

The fix reads DEFAULT_EXPIRY_DAYS for the new type after the swap and either clears expires_at or calls TypedMemory.extend_expiry — a helper that already existed on the dataclass and, until now, had no caller anywhere in mcp/.

Two memories the recompute must not touch

An unconditional recompute is a data-integrity bug of its own, so it is guarded twice:

  • A soft-deleted memory. _forget(hard=False) tombstones by setting expires_at=utcnow(). Recomputing from the new type's default would hand a deliberately forgotten memory an open-ended life — a FACT edit would make it immortal. The guard skips any record whose expires_at is at or before now.
  • An ephemeral memory. remember_handler gives these a one-day TTL and flags the anchor neuron ephemeral=True. Clearing that expiry would turn a memory documented as "auto-expires after 24h, never synced" into a permanent one. The guard reads the anchor neuron and skips the recompute when the flag is set.

In both cases the type swap still applies; only the TTL recompute is skipped. _forget is untouched and remains the only other writer of expires_at.

Changes

  • mcp/lifecycle_handler.py: import DEFAULT_EXPIRY_DAYS; after the type swap, compute the tombstone and ephemeral conditions and, when neither holds, clear or extend the expiry.
  • tests/unit/test_edit_forget.py: four tests, capturing the TypedMemory handed to storage.update_typed_memory — the interface between _edit and persistence, rather than any downstream effect. test_edit_type_change_recomputes_ttl (DECISION to FACT clears it), test_edit_type_change_picks_up_finite_expiry (FACT to TODO gains 30 days), test_edit_type_change_does_not_resurrect_soft_deleted, and test_edit_type_change_preserves_ephemeral_ttl.

Not touched, on purpose

No new argument is added to smem_edit. Exposing expires_at explicitly would be the more flexible answer, but it is a schema change and a decision for you rather than for a bug fix. If you would rather have that, this PR is easy to rebase into it.

Test plan

  • pytest tests/unit/test_edit_forget.py — 19 passed, against 15 on main; the delta is exactly the four tests added here.
  • Differential control, run twice, because the two halves of this change need separate proof:
    • Reverting the whole hunk to main and keeping the tests: 2 failed, 17 passed — the two direction tests fail, and the two guard tests pass, because with no recompute at all there is nothing for a guard to prevent.
    • Reverting only the guards, keeping the recompute: 2 failed, 17 passed — and this time it is exactly the two guard tests. The tombstone test sees its expiry resurrected; the ephemeral test sees its expiry cleared.
    • Neither pair can pass by accident, which is the property the first version of this change lacked.
  • pytest tests/ -m "not stress" -n 4 against a live SurrealDB v3.2.0 — 7287 passed, 48 skipped, 1 xfailed, which is main's 7283 plus the four tests here. Two tests in tests/unit/test_dashboard_brains_scope.py fail on this branch and on main alike: they want a live database and collide with one another under -n. Both pass when that file is run on its own.
  • ruff check src/ tests/ clean; ruff format --check src/ tests/ reports 739 files already formatted.
  • mypy src/ --ignore-missing-imports — success, no issues found in 354 source files.
  • Coverage under the CI gate: 72.39%, against 72.36% on main.
  • CHANGELOG.md untouched — left to the release entry, as with fix(storage): bind datetimes in time comparisons so they select by value #191–fix(memory): refresh content-derived fields on compress, restore, and refine #193.

Verified by

@RobertSigmundsson

…out reviving tombstones or ephemerals

_edit's type-change branch swapped memory_type and left the OLD type's TTL on
the record. DEFAULT_EXPIRY_DAYS is only consulted at creation time, via
remember_handler.expires_in_days, and TypedMemory is a frozen dataclass with no
__post_init__, so nothing recomputed it afterwards either. A DECISION (90d)
edited to FACT (no default expiry) still expired about ninety days out; the
other way round, a FACT edited to TODO or ERROR (30d each) never picked up
their finite expiry and persisted indefinitely.

After the swap, _edit now reads DEFAULT_EXPIRY_DAYS for the new type and either
clears expires_at or calls TypedMemory.extend_expiry — a helper that already
existed and had no caller in mcp/.

Two cases must be left alone, and the recompute is guarded against both:

* A soft-deleted memory. _forget(hard=False) tombstones by setting
  expires_at=utcnow(); recomputing from the new type's default would hand a
  deliberately forgotten memory an open-ended life. The guard skips any record
  whose expires_at is at or before now.
* An ephemeral memory. remember_handler gives these a one-day TTL and flags the
  anchor neuron ephemeral=True; clearing that TTL would make an
  "auto-expires, never synced" memory permanent. The guard reads the anchor
  neuron and skips the recompute when the flag is set.

The type swap itself still applies in both cases — only the TTL recompute is
skipped. _forget stays untouched; it remains the only other writer of
expires_at. No new argument is added to smem_edit: exposing expires_at
explicitly is a schema change and is the maintainer's call, not this fix's.

Four tests in tests/unit/test_edit_forget.py, one per direction and one per
guard: test_edit_type_change_recomputes_ttl (DECISION to FACT clears the
expiry), test_edit_type_change_picks_up_finite_expiry (FACT to TODO gains
one), test_edit_type_change_does_not_resurrect_soft_deleted, and
test_edit_type_change_preserves_ephemeral_ttl.

@acidkill acidkill left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff. Type change recomputes expires_at from DEFAULT_EXPIRY_DAYS[new_type]; the tombstone/ephemeral carve-outs prevent resurrecting soft-deleted memories or making ephemerals immortal. Both directions tested.

@acidkill
acidkill merged commit cf166de into acidkill:main Sep 7, 2026
9 checks passed
@acidkill acidkill mentioned this pull request Sep 7, 2026
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants