fix(engine): memories written through the soul now reach the store that owns them (closes #117) #134

Merged
tim.lingo merged 1 commits from feat/soul-write-through into main 2026-08-07 21:07:19 +00:00
Member

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 at memory.el:225-253.

What was broken

  • Sync is pull-onlyawareness.el:914-971 GETs /api/sync every ~600s and merges. There is no push path.
  • Two processes write the same file with no coordination: the soul (boot, pre-serve, every heartbeat) and the engram (persist_canonical() after every mutating route). Last writer wins. routes.el:435 already carries the acknowledgement: // TODO(reliability #8): engram_save races with awareness loop mem_save().
  • Live divergence at time of writing: engram ~79,500 nodes, soul ~99,650.

Design

New Accessor persist.el. Writes stage a delta to a filesystem spool, then push via POST /api/load-merge — deliberately not POST /api/nodes. Verified in sandbox against the real engram binary: /api/nodes mints a new server-side id (breaking dedup, orphaning edges) and drops label/tier/tags/importance/confidence — 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 retry is a no-op, and calls persist_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 InternalStateEvent sites (Will's telemetry carve-out); boot counter and persona (bespoke owner-side write-backs load-merge can't express); soul.el's 54 genesis identity edges (file-mode only). engram_strengthen/engram_forget are not propagated — load-merge cannot update or delete, and hard-deleting at the owner would fail verify-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"} where main returns {"ok":true} for a write that dies.

Two-leg proof

VERDICT [WRITE-THROUGH]              VERDICT [BASELINE-main]
 reached owner: PRESENT               reached owner: ABSENT
 survived kill -9: SURVIVED           survived kill -9: LOST

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

  1. GET /api/graph/edges was 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 in dc39a61. Now writes to a scratch path. Likely explains the live node-count divergence.
  2. Vendored runtime v1.0.0-20260501 lets fs_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 el 43636ae, which is not the pinned runtime.

Rung

E2E-VERIFIED, discriminating. Amalgam 1,176,361 B / 1243 bodies; binary 903,848 B. verify-soul-contract.sh GATE 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_save in the C runtime is untouched. No regression gate yet for "a bare engram_node_full in a durable path should fail the build".

Closes #117

🤖 Generated with Claude Code

**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 at `memory.el:225-253`. ## What was broken - Sync is **pull-only** — `awareness.el:914-971` GETs `/api/sync` every ~600s and merges. **There is no push path.** - Two processes write the **same file** with no coordination: the soul (boot, pre-serve, every heartbeat) and the engram (`persist_canonical()` after every mutating route). Last writer wins. `routes.el:435` already carries the acknowledgement: `// TODO(reliability #8): engram_save races with awareness loop mem_save()`. - Live divergence at time of writing: engram ~79,500 nodes, soul ~99,650. ## Design New Accessor `persist.el`. Writes stage a delta to a filesystem spool, then push via **`POST /api/load-merge`** — deliberately *not* `POST /api/nodes`. Verified in sandbox against the real engram binary: `/api/nodes` **mints a new server-side id** (breaking dedup, orphaning edges) and **drops label/tier/tags/importance/confidence** — 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 **retry is a no-op**, and calls `persist_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 `InternalStateEvent` sites (Will's telemetry carve-out); boot counter and persona (bespoke owner-side write-backs `load-merge` can't express); soul.el's 54 genesis identity edges (file-mode only). `engram_strengthen`/`engram_forget` are not propagated — `load-merge` cannot update or delete, and hard-deleting at the owner would fail `verify-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"}` where `main` returns `{"ok":true}` for a write that dies. ## Two-leg proof ``` VERDICT [WRITE-THROUGH] VERDICT [BASELINE-main] reached owner: PRESENT reached owner: ABSENT survived kill -9: SURVIVED survived kill -9: LOST ``` 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 1. **`GET /api/graph/edges` was 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 in `dc39a61`. Now writes to a scratch path. Likely explains the live node-count divergence. 2. Vendored runtime `v1.0.0-20260501` lets `fs_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 el `43636ae`, which is **not** the pinned runtime. ## Rung **E2E-VERIFIED, discriminating.** Amalgam 1,176,361 B / 1243 bodies; binary 903,848 B. `verify-soul-contract.sh` **GATE 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_save` in the C runtime is untouched. No regression gate yet for "a bare `engram_node_full` in a durable path should fail the build". Closes #117 🤖 Generated with [Claude Code](https://claude.com/claude-code)
tim.lingo added 1 commit 2026-08-07 18:23:56 +00:00
feat(soul): write-through to the persistence owner — memories survive restart (#117)
Neuron Soul CI / build (pull_request) Failing after 14m57s
Neuron Soul CI / deploy (pull_request) Failing after 14m39s
dd952c0e46
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>
tim.lingo merged commit 9501e4ac12 into main 2026-08-07 21:07:19 +00:00
Sign in to join this conversation.