Skip to content

Concurrent-writer policy

ENGRAM lets multiple writers touch the same memory: the web console, several agents, and the import pipeline. This document is the source of truth for how those writes are ordered. It closes cross-cutting gap G4 (see GAPS.md).

Every Memory row carries a monotonic version column (SHARED-2). An update may pass expectedVersion; the LTM update() folds it into the WHERE clause and bumps version in the same statement (a compare-and-set). A mismatch surfaces as LtmVersionConflictError → a CONFLICT: client error (HTTP 409 on the web path). At the MCP tool boundary, update_memory requires expectedVersion (both tiers); omitting it is rejected before any write with a CONFLICT:-prefixed message telling the agent to re-read (get_memory) and retry with the version it read. The update audit row’s after snapshot records the bumped version. reembed deliberately does not bump version — it rewrites the vector, not user content, so it must not invalidate a concurrent editor’s expectedVersion.

Writer Policy Rationale
Web console Requires expectedVersion (sends the version it read); a 409 opens a reload-and-rediff panel. A human editing stale content must see the conflict, never silently clobber. Already enforced (WP2).
Agent update_memory Rejects a blind updateexpectedVersion is required; omitting it returns a CONFLICT-class error explaining the memory must be re-read first. Conservative / never-lose-data (qp): agent-to-agent overwrites are the multi-writer hazard G4 names. Decision 12 (reject-blind) ENFORCED 2026-07-12 (G4-T2).
Import (import_agent_memory) CAS-skip: the importer passes the ledger’s lastWrittenVersion as expectedVersion; on conflict it skips that memory and increments a skippedConcurrentEdit counter in the run summary for the operator to reconcile. The ledger row stays stale so every later run re-reports the conflict — until the two sides converge (file content equals the memory’s current content), which the importer detects on the CAS miss and resolves by refreshing the ledger without writing the memory (reconciled counter). Never clobber a concurrent agent edit with a source-file re-import. Decision 13 (CAS-skip) ENFORCED 2026-07-13 (G4-T3).
STM (update) Atomic CASupdate() folds the version guard into the Postgres write (updateMany with version in the WHERE), so a concurrent bump matches zero rows and raises StmVersionConflictError. Non-versioned internal calls take a last-write-wins path with an atomic version bump. STM is TTL-bounded; the Postgres port folds the compare-and-set into one statement, closing the former read-compare-set window (G4-T4).
Lifecycle jobs (decay, supersede, dedup-link, access) Route through version-checked writes + emit audit where a user-visible mutation occurs. Access bookkeeping is version-keyed but NON-bumping, so a read-then-update never conflicts with its own access write. Prevents a background pass from silently clobbering a concurrent user edit. Enforced by G3-T3.

The import CAS baseline lives in the idempotency ledger: MemoryImportSource.lastWrittenVersion records the Memory.version each successful import create/update/merge wrote. NULL backfill caveat: ledger rows from before the column existed carry NULL — the first re-import of such a source cannot CAS (one last last-writer-wins update) and backfills the version it writes; every later re-import is CAS-guarded. Details and reconcile guidance: Import agent memory.

  • STM atomic CAS (G4-T4) — closed. The Postgres STM port made update() a true single-statement compare-and-set (the version guard rides in the WHERE clause), eliminating the former read-compare-set window.
  1. The web edit path always sends expectedVersion; a version-conflict test guards it.
  2. reembed never bumps version.
  3. Import must not change the MemoryImportSource @@unique([userId, sourceKey]) idempotency key while adding CAS (shared with the export→import round-trip, G6).