fix(mcp-wrapper): forget/delete tools no longer return fake ok receipts (BUG-18) #101

Merged
will.anderson merged 1 commits from fix/receipts-wrapper-forget into main 2026-08-01 15:56:38 +00:00
Member

Root cause (BUG-18)

The register cites the wrapper's forget/delete handler answering ok without checking whether the soul actually tombstoned the memory (wrapper.el:646-652 — line numbers have drifted; the source is mcp-wrapper/src/main.el). On this branch the bug survives in two forms:

  1. delete_by_id — serving removeKnowledge, deleteProcess, deleteImprint, dischargeWonderfabricated {"ok":true,"deleted":"<id>","note":"soft-deleted"} without calling the soul at all. Its "soul does not yet expose a delete HTTP route" comment was stale: /api/neuron/node/delete exists, tombstones any node type, and errors on unknown ids.
  2. tool_forget forwarded the soul's response but never verified the deletion actually persisted before answering ok.

The change

Implements Receipt Contract rule 1 (docs repo, docs/specs/RECEIPT-CONTRACT-2026-07-22.md): a tool result must reflect what actually happened.

  • delete_by_id now routes to the soul's real /api/neuron/node/delete and propagates its answer (honest node not found for bad ids).
  • Both handlers read back before answering ok: GET /api/neuron/graph?id=<id>&depth=1 must show the tombstone marker (label "tombstone:<id>"); otherwise they answer {"ok":false,"error":"delete_not_persisted","id":"<id>"} — the soul's own not-persisted error shape (api_not_persisted in neuron-api.el).
  • Soul errors and transport failures pass through unchanged.

Minimal diff: 31 insertions, 2 deletions, one file.

How to test (exact sandbox repro)

Build soul + wrapper with elb against the vendored release runtime v1.0.0-20260501 (same pin as #97 — the AR "latest"/dev runtime no longer defines engram_prune_telemetry; soul build dir pre-seeded with dist/elp-c-decls.h; link with -DHAVE_CURL -rdynamic). Boot sandboxed (throwaway HOME, SOUL_ENGRAM_PATH to a scratch file, no API keys, dead axon URL, tick timers 3600s):

# soul on :7791, then:
SOUL_URL=http://127.0.0.1:7791 MCP_PORT=7792 ./neuron-mcp-wrapper

curl -s -X POST http://127.0.0.1:7792/ -H 'Content-Type: application/json' -d \
 '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"removeKnowledge","arguments":{"node_id":"kn-DOES-NOT-EXIST-deadbeef"}}}'

Before (unpatched, branch parent b784750)

removeKnowledge(nonexistent) -> {"ok":true,"deleted":"kn-DOES-NOT-EXIST-deadbeef","note":"soft-deleted"}   <- lie: no soul call, nothing deleted
forget(nonexistent)          -> {"error":"memory not found: ..."}   (soul's answer passed through — already honest here)
forget(soul down)            -> {"error":"Failed to connect to 127.0.0.1 port 9 ..."}   (transport error passed through)

After (this branch)

removeKnowledge(nonexistent) -> {"error":"node not found: kn-DOES-NOT-EXIST-deadbeef"}   (the soul's real answer)
forget(nonexistent)          -> {"error":"memory not found: ..."}   (unchanged)
happy path: remember -> forget -> {"ok":true,"id":"...","tombstoned":true}
  read-back: id HIDDEN from default /api/neuron/list/Memory, PRESENT with ?include_deleted=1,
  node KEPT in the full graph view (immutability intact),
  tombstone marker "tombstone:<id>" verified via /api/neuron/graph?id=<id>&depth=1
happy path: captureKnowledge -> removeKnowledge -> {"ok":true,...,"tombstoned":true} + marker verified

Regression gate

scripts/verify-soul-contract.sh (from main) against the elb-built soul this wrapper talks to (this branch's soul sources): GATE PASS — PRESENCE 27/27 answered, IMMUTABILITY pass (all mutation routes tombstone/supersede, no hard-delete).

Note on dist/

No dist/ artifacts are touched — the combined-unit assembly step is Will's, and the committed dist already lags the .el sources by 8 modules.

🤖 Generated with Claude Code

## Root cause (BUG-18) The register cites the wrapper's forget/delete handler answering ok without checking whether the soul actually tombstoned the memory (wrapper.el:646-652 — line numbers have drifted; the source is `mcp-wrapper/src/main.el`). On this branch the bug survives in two forms: 1. **`delete_by_id`** — serving `removeKnowledge`, `deleteProcess`, `deleteImprint`, `dischargeWonder` — **fabricated** `{"ok":true,"deleted":"<id>","note":"soft-deleted"}` **without calling the soul at all**. Its "soul does not yet expose a delete HTTP route" comment was stale: `/api/neuron/node/delete` exists, tombstones any node type, and errors on unknown ids. 2. **`tool_forget`** forwarded the soul's response but never verified the deletion actually persisted before answering ok. ## The change Implements **Receipt Contract rule 1** (docs repo, `docs/specs/RECEIPT-CONTRACT-2026-07-22.md`): a tool result must reflect what actually happened. - `delete_by_id` now routes to the soul's real `/api/neuron/node/delete` and propagates its answer (honest `node not found` for bad ids). - Both handlers **read back before answering ok**: `GET /api/neuron/graph?id=<id>&depth=1` must show the tombstone marker (`label "tombstone:<id>"`); otherwise they answer `{"ok":false,"error":"delete_not_persisted","id":"<id>"}` — the soul's own not-persisted error shape (`api_not_persisted` in neuron-api.el). - Soul errors and transport failures pass through unchanged. Minimal diff: 31 insertions, 2 deletions, one file. ## How to test (exact sandbox repro) Build soul + wrapper with elb against the vendored release runtime `v1.0.0-20260501` (same pin as #97 — the AR "latest"/dev runtime no longer defines `engram_prune_telemetry`; soul build dir pre-seeded with `dist/elp-c-decls.h`; link with `-DHAVE_CURL -rdynamic`). Boot sandboxed (throwaway HOME, `SOUL_ENGRAM_PATH` to a scratch file, no API keys, dead axon URL, tick timers 3600s): ``` # soul on :7791, then: SOUL_URL=http://127.0.0.1:7791 MCP_PORT=7792 ./neuron-mcp-wrapper curl -s -X POST http://127.0.0.1:7792/ -H 'Content-Type: application/json' -d \ '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"removeKnowledge","arguments":{"node_id":"kn-DOES-NOT-EXIST-deadbeef"}}}' ``` ### Before (unpatched, branch parent b784750) ``` removeKnowledge(nonexistent) -> {"ok":true,"deleted":"kn-DOES-NOT-EXIST-deadbeef","note":"soft-deleted"} <- lie: no soul call, nothing deleted forget(nonexistent) -> {"error":"memory not found: ..."} (soul's answer passed through — already honest here) forget(soul down) -> {"error":"Failed to connect to 127.0.0.1 port 9 ..."} (transport error passed through) ``` ### After (this branch) ``` removeKnowledge(nonexistent) -> {"error":"node not found: kn-DOES-NOT-EXIST-deadbeef"} (the soul's real answer) forget(nonexistent) -> {"error":"memory not found: ..."} (unchanged) happy path: remember -> forget -> {"ok":true,"id":"...","tombstoned":true} read-back: id HIDDEN from default /api/neuron/list/Memory, PRESENT with ?include_deleted=1, node KEPT in the full graph view (immutability intact), tombstone marker "tombstone:<id>" verified via /api/neuron/graph?id=<id>&depth=1 happy path: captureKnowledge -> removeKnowledge -> {"ok":true,...,"tombstoned":true} + marker verified ``` ## Regression gate `scripts/verify-soul-contract.sh` (from main) against the elb-built soul this wrapper talks to (this branch's soul sources): **GATE PASS** — PRESENCE 27/27 answered, IMMUTABILITY pass (all mutation routes tombstone/supersede, no hard-delete). ## Note on dist/ No `dist/` artifacts are touched — the combined-unit assembly step is Will's, and the committed dist already lags the `.el` sources by 8 modules. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
will.anderson reviewed 2026-07-28 03:19:58 +00:00
will.anderson left a comment
Owner

Review: honest forget/delete receipts + immutability (BUG-18)

Verdict: looks good / approve. Immutability doctrine confirmed intact -- this PR does not reintroduce any hard-delete or data loss; it removes a fabricated-receipt path and replaces it with real tombstoning.

Immutability (the critical check -- verified end to end)

  • delete_by_id now POSTs /api/neuron/node/delete -> handle_api_node_delete (neuron-api.el:258) -> tombstone_node -> mem_tombstone (memory.el:52). mem_tombstone creates a Tombstone marker node (label tombstone:<id>), keeps the original node and all incident edges, and wires a tombstones edge. Never engram_forget.
  • tool_forget routes /api/neuron/memory/delete -> handle_api_memory_delete (neuron-api.el:620) -- same tombstone path, node + edges KEPT.
  • The old delete_by_id was worse than a false ok: it never called the soul at all and fabricated {"ok":true,...,"note":"soft-deleted"}. Replacing that with a real immutable tombstone is a strict improvement to the doctrine. No hard-delete reintroduced anywhere.

Correctness -- read-back verification is sound

  • GET /graph?id=<id>&depth=1 -> handle_api_inspect_graph -> engram_neighbors_json(id, depth, "both") and it does not apply memory_hide_tombstoned (that hide is bounded-list-only, neuron-api.el:138-144). So the Tombstone marker is visible at depth 1 (reachable via the incoming tombstones edge, direction both), and its serialized label tombstone:<id> is what str_contains(check, "tombstone:" + id) matches. Read-back holds.
  • Happy-path detection str_contains(resp, '"ok":true') matches the soul's {"ok":true,"id":...,"tombstoned":true}; soul errors (node not found, memory not found, api_err_protected) contain no "ok":true and pass through honestly. Good.

Two minor nits (non-blocking)

  1. The not-persisted return is {"ok":false,"error":"delete_not_persisted",...}, but the PR body says it uses "the soul's own not-persisted error shape (api_not_persisted)" -- the soul's actual shape is write_not_persisted (neuron-api.el:104). The distinct string is arguably clearer, but the description slightly misstates it. Cosmetic.
  2. Double-delete of an already-tombstoned id: the original node is (correctly) still present, so handle_api_node_delete tombstones again and creates a second marker; read-back still passes. Harmless but produces duplicate markers -- optional idempotency guard (skip if a tombstone:<id> marker already exists).

Base-branch flag

This PR targets hotfix/elc-source-typos, not main. main is the authoritative branch now, and there is already a reconcile/hotfix-to-main-launch line in flight. As-is, this honest-receipt fix lands on a stale hotfix branch and risks being stranded there rather than reaching main. @tim.lingo / @will: please confirm the intended landing path -- either retarget to main, or guarantee this flows through the reconcile branch. Flagging, not blocking.

## Review: honest forget/delete receipts + immutability (BUG-18) **Verdict: looks good / approve. Immutability doctrine confirmed intact** -- this PR does not reintroduce any hard-delete or data loss; it removes a fabricated-receipt path and replaces it with real tombstoning. ### Immutability (the critical check -- verified end to end) - `delete_by_id` now POSTs `/api/neuron/node/delete` -> `handle_api_node_delete` (neuron-api.el:258) -> `tombstone_node` -> `mem_tombstone` (memory.el:52). `mem_tombstone` creates a `Tombstone` marker node (label `tombstone:<id>`), **keeps the original node and all incident edges**, and wires a `tombstones` edge. Never `engram_forget`. - `tool_forget` routes `/api/neuron/memory/delete` -> `handle_api_memory_delete` (neuron-api.el:620) -- same tombstone path, node + edges KEPT. - The old `delete_by_id` was worse than a false ok: it **never called the soul at all** and fabricated `{"ok":true,...,"note":"soft-deleted"}`. Replacing that with a real immutable tombstone is a strict improvement to the doctrine. No hard-delete reintroduced anywhere. ### Correctness -- read-back verification is sound - `GET /graph?id=<id>&depth=1` -> `handle_api_inspect_graph` -> `engram_neighbors_json(id, depth, "both")` and it **does not** apply `memory_hide_tombstoned` (that hide is bounded-list-only, neuron-api.el:138-144). So the `Tombstone` marker is visible at depth 1 (reachable via the incoming `tombstones` edge, direction `both`), and its serialized `label` `tombstone:<id>` is what `str_contains(check, "tombstone:" + id)` matches. Read-back holds. - Happy-path detection `str_contains(resp, '"ok":true')` matches the soul's `{"ok":true,"id":...,"tombstoned":true}`; soul errors (`node not found`, `memory not found`, `api_err_protected`) contain no `"ok":true` and pass through honestly. Good. ### Two minor nits (non-blocking) 1. The not-persisted return is `{"ok":false,"error":"delete_not_persisted",...}`, but the PR body says it uses "the soul's own not-persisted error shape (`api_not_persisted`)" -- the soul's actual shape is `write_not_persisted` (neuron-api.el:104). The distinct string is arguably clearer, but the description slightly misstates it. Cosmetic. 2. Double-delete of an already-tombstoned id: the original node is (correctly) still present, so `handle_api_node_delete` tombstones again and creates a second marker; read-back still passes. Harmless but produces duplicate markers -- optional idempotency guard (skip if a `tombstone:<id>` marker already exists). ### Base-branch flag This PR targets `hotfix/elc-source-typos`, not `main`. `main` is the authoritative branch now, and there is already a `reconcile/hotfix-to-main-launch` line in flight. As-is, this honest-receipt fix lands on a stale hotfix branch and risks being stranded there rather than reaching `main`. @tim.lingo / @will: please confirm the intended landing path -- either retarget to `main`, or guarantee this flows through the reconcile branch. Flagging, not blocking.
will.anderson changed target branch from hotfix/elc-source-typos to main 2026-08-01 15:56:17 +00:00
will.anderson added 1 commit 2026-08-01 15:56:17 +00:00
Root cause: two false-receipt paths in the wrapper's delete family.
- delete_by_id (removeKnowledge, deleteProcess, deleteImprint,
  dischargeWonder) FABRICATED {"ok":true,...,"note":"soft-deleted"}
  without calling the soul at all — the 'soul does not yet expose a delete
  HTTP route' note was stale (/api/neuron/node/delete exists and tombstones
  any node type).
- tool_forget forwarded the soul's response but never verified the deletion
  actually persisted before answering ok.

The change (Receipt Contract rule 1 — a tool result must reflect what
actually happened):
- delete_by_id now routes to the soul's real /api/neuron/node/delete and
  propagates its answer (honest 'node not found' for bad ids).
- Both handlers read back before answering ok: GET /api/neuron/graph?id=..
  &depth=1 must show the tombstone marker (label "tombstone:<id>"); if it
  does not, answer {"ok":false,"error":"delete_not_persisted",...} in
  the soul's not-persisted error shape (api_not_persisted).
- Soul errors and transport failures pass through unchanged.

E2E evidence (sandbox soul :7791 + wrapper :7792, elb builds):
- unpatched: removeKnowledge on a NONEXISTENT id -> {"ok":true,
  "deleted":"kn-DOES-NOT-EXIST-deadbeef","note":"soft-deleted"} (lie)
- patched:   same call -> {"error":"node not found: ..."} (soul's answer)
- happy path: remember -> forget -> {"ok":true,"tombstoned":true};
  read-back: hidden from default /list/Memory, present with
  ?include_deleted=1, node KEPT in full graph view (immutability intact)
- scripts/verify-soul-contract.sh on the soul it talks to: GATE PASS
  (27/27 presence + immutability)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
will.anderson merged commit ec219c5830 into main 2026-08-01 15:56:38 +00:00
Sign in to join this conversation.