fix(engine): memories written through the soul now reach the store that owns them (closes #117) #134
Reference in New Issue
Block a user
Delete Branch "feat/soul-write-through"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Memories written through the soul never reach the durable store. This builds the missing half. Issue #117.
The contract, corrected
Do not cite claim 17 for this. Engram provisional claims 15-18 scope delta-sync to peer Engram instances; claim 17's pull-then-push is peer↔peer. The soul is a caller of the database API (cf. claim 27), not a peer — so claim 17 specifies nothing about soul↔engram. Our earlier framing of #117 borrowed his words for a relationship he wasn't describing.
The design is derived instead from the ownership rule he did state,
soul.el:571-573: "when ENGRAM_URL is set the HTTP Engram owns persistence — the soul must NEVER write to the local snapshot." Plus his two working precedents: the persona and boot-counter write-backs atmemory.el:225-253.What was broken
awareness.el:914-971GETs/api/syncevery ~600s and merges. There is no push path.persist_canonical()after every mutating route). Last writer wins.routes.el:435already carries the acknowledgement:// TODO(reliability #8): engram_save races with awareness loop mem_save().Design
New Accessor
persist.el. Writes stage a delta to a filesystem spool, then push viaPOST /api/load-merge— deliberately notPOST /api/nodes. Verified in sandbox against the real engram binary:/api/nodesmints a new server-side id (breaking dedup, orphaning edges) and drops label/tier/tags/importance/confidence — atier:"Canonical"probe came back"Working".load-mergepreserves the id and every field, dedups nodes by id and edges by (from,to,relation) so retry is a no-op, and callspersist_canonical()so the owner writes its own file. The soul never touches it.Spool-and-drain rather than push-per-write: measured ~0.38s per load-merge at live scale (79k nodes / 176MB) and a chat turn writes 5-7 nodes. The spool is on disk rather than in process state because the soul serves each connection on its own thread — which also buys crash recovery.
Covered: 35 node sites + 9 edge sites across memory/neuron-api/chat/sessions/routes/safety/stewardship.
Skipped, with reasons in-file: 4
InternalStateEventsites (Will's telemetry carve-out); boot counter and persona (bespoke owner-side write-backsload-mergecan't express); soul.el's 54 genesis identity edges (file-mode only).engram_strengthen/engram_forgetare not propagated —load-mergecannot update or delete, and hard-deleting at the owner would failverify-soul-contract.sh§B. Tombstone nodes are pushed.Honesty behaviour
Unreachable and rejected are distinguished and logged separately; deltas are retained and retried on heartbeat and boot.
api_persisted— the gate all 10 MCP write handlers pass — now asserts at the owner rather than in the soul's own memory. With the owner down it returns{"ok":false,"error":"write_not_persisted"}wheremainreturns{"ok":true}for a write that dies.Two-leg proof
Plus crash recovery: write with owner down →
kill -9→ owner up → soul restart → node appeared at the owner in 0.3s with zero HTTP requests made to the restarted soul.Two defects found and fixed en route
GET /api/graph/edgeswas overwriting the owner's canonical snapshot on every call — a read route, in a non-owner process, clobbering the authoritative file. Same class Will removed from the engram indc39a61. Now writes to a scratch path. Likely explains the live node-count divergence.v1.0.0-20260501letsfs_read's length leak into the next response's Content-Length — reading a spool file mid-request turned an 86-byte reply into 497 bytes with 411 bytes of adjacent heap trailing it. Fixed at our boundary; the runtime class was fixed upstream in el43636ae, which is not the pinned runtime.Rung
E2E-VERIFIED, discriminating. Amalgam 1,176,361 B / 1243 bodies; binary 903,848 B.
verify-soul-contract.shGATE PASS on both builds (27/27 routes, immutability 5/5). Live services were never written to — GET only throughout.Not verified: multi-threaded concurrent-write load; behaviour when soul and engram are not co-located (the spool path is handed to the owner, so same-host is assumed — true for the dev-stack and the GKE image). The non-atomic
engram_savein the C runtime is untouched. No regression gate yet for "a bareengram_node_fullin a durable path should fail the build".Closes #117
🤖 Generated with Claude Code
The soul obeys half of its own ownership rule. soul.el:571-573 says "when ENGRAM_URL is set the HTTP Engram owns persistence — the soul must NEVER write to the local snapshot", and it doesn't. But nothing was ever built to hand the soul's writes TO that owner: sync is pull-only (/api/sync -> engram_load_merge), so every node created inside the soul lived in process RAM and was shed on restart. Measured live 2026-08-07: soul node_count=102184, engram 79197. SCOPE CORRECTION vs the earlier internal spec: engram provisional claim 17's "pull-then-push" is a PEER-ENGRAM to PEER-ENGRAM protocol (claims 15-18 say so explicitly). The soul is a CALLER of the database API, not a peer. Claim 17 is NOT authority for a soul<->engram contract and is no longer cited as such. The design here follows from the ownership rule alone. Mechanism: a new Accessor, persist.el, is the single boundary. Writes stage a delta to a filesystem spool and are pushed to the owner via POST /api/load-merge — NOT POST /api/nodes, which mints a new server-side id (breaking dedup and edges) and drops label/tier/tags/importance/confidence (verified in a sandbox: a tier "Canonical" probe came back "Working"). load-merge preserves the id and every field, dedups nodes by id and edges by (from,to,relation) so retries are no-ops, and calls persist_canonical() so THE OWNER writes its own file — the ownership rule is honoured rather than worked around. Spool-and-drain rather than push-per-write: measured ~0.38s per load-merge at live scale (79k nodes/176MB), and a chat turn writes 5-7 nodes. The spool is on disk, not in process state, because the soul serves each connection on its own pthread and a shared buffer would lose entries to a read-modify-write race. That also buys crash recovery: writes orphaned by kill -9 are drained on next boot. Honesty: api_persisted (the gate all 10 MCP write handlers pass through) and mem_store now assert AT THE OWNER instead of reading back the soul's own RAM. With the owner down a write returns {"ok":false,"error":"write_not_persisted"} and the delta is queued — where main returns {"ok":true} for a write that dies. Coverage: 35 node sites + 9 edge sites routed through the boundary. Deliberately excluded, with reasons in persist.el: 4 InternalStateEvent sites (Will's own telemetry carve-out), the boot counter and the persona (both already have bespoke owner-side write-backs), and soul.el's 54 genesis identity edges (file-mode only). engram_strengthen and engram_forget are NOT propagated — load-merge cannot update or delete, and hard-deleting at the owner would fail verify-soul-contract.sh section B. Also fixed here: - routes.el GET /api/graph/edges engram_save()'d straight over the owner's canonical snapshot.json — a read route, in a non-owner process, clobbering the canonical on every call. Same defect class Will removed from the engram in el dc39a61. Now exports to a scratch path. With this gone the soul writes nothing at all in HTTP mode. - persist.el must clear the runtime's _tl_fs_read_len hint after every fs_read. In vendored runtime v1.0.0-20260501 that hint becomes the NEXT response's Content-Length, so reading a spool file mid-request made an 86-byte reply go out as 497 bytes with 411 bytes of adjacent heap trailing it. Caught and fixed at our boundary; the runtime class was fixed upstream in el 43636ae, which is not the pinned runtime here. Rung: E2E-VERIFIED, discriminating. Same harness, same engram binary: write-through: LEG 1 PRESENT at owner, LEG 2 SURVIVED kill -9 + restart main: LEG 1 ABSENT at owner, LEG 2 LOST verify-soul-contract.sh: GATE PASS on both builds (27/27 routes, immutability). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>