Skip to content

feat: memory lifecycle — status, expiry, history and conditional saves (#33) - #45

Merged
stormer78 merged 1 commit into
mainfrom
feat/memory-lifecycle
Nov 11, 2026
Merged

stormer78 merged 1 commit into
mainfrom
feat/memory-lifecycle

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Closes #33.

What changes

Saves no longer overwrite blind, and nothing they replace is lost. Store::save_versioned:

  1. reads the current version;
  2. if the caller passed expectedVersion (from a recall/get; 0 = must be new) and it doesn't match, writes nothing and returns a conflict;
  3. copies the current version verbatim, still signed by its own author, to archive/<type>/<slug>/v<n> and links it as supersedes;
  4. writes the new record conditionally (put/0.3 expectedVersion) on the version from step 1 — a writer that landed in between wins, and the losing save returns SaveOutcome::Conflict with the checked winner (a forged winner is a Refused, not an Entry);
  5. prunes history to 5 versions.

History keys are outside every <type>/ prefix, so recall and typed lists never reach them. memory_get with history: true returns them, each verified against the key it was originally written to.

Lifecycle fields, all signed: status (active | deprecated | disputed | retracted) + required statusReason, validUntil, reviewAfter, supersedes.

  • Recall excludes retracted and expired; demotes deprecated/disputed (half weight) and shows the status + reason; flags stale past reviewAfter.
  • memory_list shows everything with its status; stale: true narrows to due-for-review/expired.

New MCP tools: memory_dispute, memory_deprecate, memory_retract (each takes a reason and optional expectedVersion). Retract leaves a content-free tombstone (content survives only in history) and is the only status change allowed on a refused record — anything else would re-sign content nobody vouches for. A status change is a new version signed by whoever made it; the original author's version is in history.

memory_save gains expectedVersion, validUntil, reviewAfter; a conflict returns saved: false with the current version and value (fenced). memory_forget stays the hard delete and now removes history too. /memories --stale walks the user through review; /forget offers retract.

Skill, commands, README, CLAUDE.md updated. Plugin and crate → 0.6.0.

Acceptance

  • Status, validUntil and supersedes round-trip and are honoured by recall (a_replacing_save_keeps_the_previous_version_in_history, disputed_and_deprecated_memories_are_demoted_not_hidden, expired_memories_drop_out_and_stale_ones_are_flagged).
  • A conflicting concurrent save is rejected, with the winning version returned — both the stale-read path and the VTA-side race in the read/write window (a_save_based_on_a_stale_read_is_rejected_with_the_winner, a_concurrent_save_in_the_read_write_window_is_rejected_by_the_vta; the fake keyspace now implements expectedVersion and versionConflict as the VTA does).
  • Retract keeps a tombstone; forget remains the hard-delete path (retracting_leaves_a_tombstone_and_forgetting_removes_everything).

Notes

  • A save is now ~3 round trips (get, archive put, conditional put) plus a list to prune when replacing. Memory saves are rare; correctness wins.
  • Not exercised against a live VTA — relies on put/0.3 expectedVersion / versionConflict as specified (VTI#2115).

Testing

cargo fmt, cargo clippy --all-targets clean; 102 unit + 41 roundtrip + binary/hook/stdio suites pass.

#33)

A save no longer overwrites blind or loses what it replaces:

- It reads the current version, copies it verbatim (still signed by its
  own author) to `archive/<type>/<slug>/v<n>`, links it as `supersedes`,
  and writes conditionally (put/0.3 `expectedVersion`) on the version it
  read. A writer that got in first wins; the losing save comes back as
  SaveOutcome::Conflict carrying the checked winner. Callers that read
  earlier pass that `version` as `expectedVersion` (0 = must be new).
- History keys sit outside every `<type>/` prefix, so recall never sees
  them; at most five versions are kept per memory. `memory_get` with
  `history: true` returns them, each verified against its original key.

Records gain a signed lifecycle: `status` (active | deprecated | disputed
| retracted) with a required `statusReason`, `validUntil` and
`reviewAfter`. Recall leaves out retracted and expired memories, ranks
deprecated and disputed ones at half weight, and flags `stale` ones past
their review date. `memory_list` still shows everything, and
`stale: true` narrows it to what needs review.

New tools: memory_dispute, memory_deprecate, memory_retract. Retract
leaves a content-free tombstone; it is the only status change allowed on
a refused record. `memory_forget` stays the hard delete and now removes
the history too. `/memories --stale` walks the user through review.

Bumps the plugin to 0.6.0.

Closes #33

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit 37a890b into main Nov 11, 2026
4 checks passed
@stormer78
stormer78 deleted the feat/memory-lifecycle branch November 11, 2026 00:02
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.

Memory lifecycle: status, expiry, supersede and dispute instead of overwrite

1 participant