Repository navigation
feat: memory versions, expected_version and superseded_by - #745
Conversation
…version Migration 00120 adds memories.version (NOT NULL DEFAULT 1, so existing rows read 1 without a backfill). A BEFORE INSERT OR UPDATE trigger owns it: an insert starts at 1, and an update gets old + 1 exactly when value, content_type, attributes, tags or a non-null supersedes changed, so every write path obeys the rule and no statement can set the number itself. TTL, counters, embeddings, archive/restore, owner moves, identical rewrites and a link cleared by its target's deletion leave it alone. memory_write gains p_expected_version and returns version. A stale value raises LK003 version_conflict naming the stored version; 0 means the key must not exist. The live row is locked FOR UPDATE for the check and every do-update re-states it, so a concurrent writer cannot slip between check and write. lorekit_memory_list returns version too. Claude-Session: https://claude.ai/code/session_012YX3RPBKN75heHAMkL2Toa
memory.write (MCP, REST POST /memories, mcp-core) accepts expected_version, validated by the shared record-extensions parser, and returns version. The RPC's LK003 refusal becomes a VersionConflictError: an isError result on MCP, a 409 with code version_conflict and current_version on REST. PATCH /memories/:id takes expected_version as a predicate on the update itself and answers 409 when the row it could have patched has moved on. Reads return version, and superseded_by: the ids of the live records the caller can see whose supersedes names this one, fetched with ONE query per page under the read's own tenant filter (MCP read/refs/list/search; REST get/read/list/search). Omitted when empty; list and search filtering is unchanged. lorekit_memory_list returns version for REST list. Catalog, OpenAPI and llms.txt (template + regenerated) document it. Claude-Session: https://claude.ai/code/session_012YX3RPBKN75heHAMkL2Toa
…eded_by The local store appends `version` to the frontmatter: 1 on a first write or a revived archived key, +1 on a content change (value, content_type, merged attributes, tags, a new supersedes link) under the hosted trigger's rule, and kept otherwise. `expected_version` is checked against the live record (0 when absent) before anything is written; a mismatch returns code version_conflict with the current version. read/readMany attach superseded_by across both tiers. `lorekit write --expected-version N` forwards the check to either store, exits 1 with the stored version on a conflict, and reports the new version; show and list --json carry version. A remote write error object no longer prints as [object Object]. Claude-Session: https://claude.ai/code/session_012YX3RPBKN75heHAMkL2Toa
docs/mcp-tools.md (the expected_version parameter, a Versions section, and version / superseded_by on every read return shape), docs/cli.md (versions and --expected-version on write), the CI state-record use case (guard overlapping jobs with --expected-version), and docs/key-files.md (superseded-by.ts and migration 00120). Claude-Session: https://claude.ai/code/session_012YX3RPBKN75heHAMkL2Toa
|
The dashboard preview for this PR — redeployed on each push that changes the web app.
Preview always points at this PR's latest commit; Deployment is the immutable build of |
✅ No issues foundSince 8cf7aba: PATCH /memories/:id now 404s on an expired memory via a shared NOT_EXPIRED filter (new tests fail without it), and the catalog/llms.txt describe expired-as-version-0 and revive-clears-TTL. No new findings. <a href=" Review details
Nothing to report — measurability (skipped), severity, 0 files skipped.
Generated by Claude Code |
mthines
left a comment
There was a problem hiding this comment.
Generated by Claude Code
…esolves TwoTierStore.write compared expected_version against the target tier's copy only, while TwoTierStore.read falls through to home. With a project tier active and the key only in home at version 2, --expected-version 2 was refused with current 0. The check now uses the two-tier read's answer, and a project copy written over a home-only record continues its version (it shadows that record, so a reader never sees a reset to 1). Claude-Session: https://claude.ai/code/session_012YX3RPBKN75heHAMkL2Toa
Every read hides a row whose expires_at has passed, but the version check treated it as existing, so expected_version 0 was refused for a key a reader saw as missing. memory_write's gate, its do-update guards and its conflict report now read an expired row as version 0; the local store uses isLive for the check. The expired row is still rewritten in place (same id, created_at, seen_count), so its version continues counting rather than restarting at 1 — restarting would need the bump trigger to judge liveness on every update. Regenerates the BYOD bootstrap; adds §120 AC-2k..n. Claude-Session: https://claude.ai/code/session_012YX3RPBKN75heHAMkL2Toa
Adding the CHECK directly scans every row under the ADD COLUMN's ACCESS EXCLUSIVE lock. It is now added NOT VALID (catalog-only) and validated in a separate statement, which needs only SHARE UPDATE EXCLUSIVE. Validation is safe: every existing row reads version 1 from the default. Regenerates the BYOD bootstrap; §120 AC-1r asserts the constraint ends validated. Claude-Session: https://claude.ai/code/session_012YX3RPBKN75heHAMkL2Toa
mthines
left a comment
There was a problem hiding this comment.
Generated by Claude Code
A migration file runs in one transaction, so the VALIDATE in 00120 scanned while the ACCESS EXCLUSIVE lock from `add column` was still held. 00120 now adds the CHECK NOT VALID only; 00121 validates it in its own transaction under SHARE UPDATE EXCLUSIVE. Corrects the 00120 comment, regenerates the BYOD bootstrap, and points §120 AC-1r at 00121. Claude-Session: https://claude.ai/code/session_012YX3RPBKN75heHAMkL2Toa
…rd clears its past TTL A write whose expected_version check passed because the stored row was expired updated that row in place but, naming no TTL, kept the past expires_at, so the record stayed hidden and the nightly purge deleted it. memory_write (00120) and the local store now clear expires_at in that case (what a fresh record with no TTL gets); an explicit TTL is still honoured, and a write without expected_version is unchanged. Adds §120 AC-2o..q (without p_clear_ttl) and a local test. Claude-Session: https://claude.ai/code/session_012YX3RPBKN75heHAMkL2Toa
…ackwards With an expired project copy at v2 and a live home copy at v5, a write with expected_version 5 continued from the project copy and landed at v3. The local store now continues from the max of this tier's stored copy and the record the two-tier read resolved, comparing content against what the reader saw. Adds a test reproducing v5 -> v6. Claude-Session: https://claude.ai/code/session_012YX3RPBKN75heHAMkL2Toa
… and a revive clears its TTL The expected_version and ttl_days descriptions now state that an archived or expired memory counts as absent (version 0), and that an expected_version write reviving an expired row with no TTL named clears the past expiry. docs/mcp-tools.md names every TTL field. Edge mirror, surfaces and llms.txt regenerated. Claude-Session: https://claude.ai/code/session_012YX3RPBKN75heHAMkL2Toa
…ke GET PATCH's target() filtered archived rows only, so it could edit a row GET /:id 404s on, and its expected_version 409 lookup reported an expired row's version where memory_write counts it as 0. The target now carries the same unexpired predicate as get.ts (NOT_EXPIRED, one shared constant for both handlers), so an expired row is a 404 for the update and the conflict lookup alike. OpenAPI description says so; edge test covers 404/409/200. Claude-Session: https://claude.ai/code/session_012YX3RPBKN75heHAMkL2Toa
Memories get corrected: a runbook from January is fixed in March. Today a reader can't tell. Writing to the same scope and key overwrites in place and only
updated_atchanges, andsupersedes(#736) sits on the newer record, so a read of the old record shows nothing.What changes
versionon every record, hosted and local. It starts at 1 and goes up only when the content changes: value, content_type, attributes, tags, or asupersedeslink being set or changed.versiona statement sets.on delete set nulldon't bump it.expected_version(optional) on MCPmemory.write, REST create/update andlorekit write --expected-version N.version_conflictwith the current version: REST 409, an MCPisErrorresult, CLI exit 1.0means "must not exist yet".memory_write.expected_versionkeeps today's behaviour.superseded_by, computed on read and never stored: the ids of live records that name this one insupersedesand that the caller can see.GET /memories/{id}. The sharedNOT_EXPIREDfilter keeps the two in step.version >= 1check asnot valid. 00121 validates it in its own transaction, so the scan doesn't run under theadd columnlock.Limits and out of scope
superseded_byonshow/read only, not on list or search, which would walk the whole store on the hook's hot path.migrateto remote doesn't carryversion; the server starts the counter at 1.expected_version) to an expired key still keeps its past expiry, as onmain. Changing that affects TTL behaviour for every writer, so it's left for a separate change.Docs:
docs/mcp-tools.md,docs/cli.md,use-cases.mdx, the OpenAPI spec (409s, PATCH 404s), the tool catalog andllms.txt(regenerated).bootstrap.sql, the edge mirrors andsurfaces.generated.mjsare regenerated.Verification
migrations.test.sql§120 passes on a throwaway PG16 cluster with every migration through 00121.deno test: 73 passed.deno checkstays within the baseline.pnpm nx test cli: green except the known loopback failures, which fail the same way onmain(see docs/sandbox.md).pnpm nx test schemas: 343 of 343.--checkmodes and the llms drift check are clean.Follows from the "version rule" point raised on the blog post (#744).
https://claude.ai/code/session_012YX3RPBKN75heHAMkL2Toa