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).
Mechanism
Section titled “Mechanism”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.
Policy by writer
Section titled “Policy by writer”| 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 update — expectedVersion 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 CAS — update() 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.
Deliberate deferrals
Section titled “Deliberate deferrals”- STM atomic CAS (G4-T4) — closed. The Postgres STM port made
update()a true single-statement compare-and-set (theversionguard rides in theWHEREclause), eliminating the former read-compare-set window.
Invariants (must not regress)
Section titled “Invariants (must not regress)”- The web edit path always sends
expectedVersion; a version-conflict test guards it. reembednever bumpsversion.- Import must not change the
MemoryImportSource@@unique([userId, sourceKey])idempotency key while adding CAS (shared with the export→import round-trip, G6).