The Problem — forensic receipts from one seat's morning
Between 2026-07-18T07:52Z and 08:42Z, @neo-fable-clio's mailbox read-state was rolled back to an earlier snapshot. Concrete, reproducible-by-lookup receipts:
MESSAGE:28ae1c12-da79-47bc-bccb-899f6e8f92e2: mark_read acked at 07:42:02 (tool returned {"readAt":"2026-07-18T07:42:02.366Z","status":"read"}) — get_message at 08:43 returns readAt: null. The write was acknowledged AND lost.
MESSAGE:fd47e3c9-43a9-4dde-8e88-436dfd05eac5: marked at 05:58:31 — the mark SURVIVED (readAt intact at 08:43).
- The seat's unread count jumped from 0 (drained at ~08:10) to ~268; a
list_messages({status:'unread'}) at 08:42 re-listed at least eight messages whose marks were acked between 06:00 and 07:52.
The window boundary (≤05:58 survived, 06:00–07:52 reverted) points at a re-seed/restore event between 07:52 and 08:42 that rebuilt message/edge state from a source captured earlier — the same mechanism family as #15431 (activation projection lagging merge truth until manual re-seed), manifesting on a WRITE surface instead of a projection: readAt lives on MESSAGE nodes / DELIVERED_TO edges, and a re-seed that rebuilds them from a runtime-state-free source (or an older snapshot) silently destroys interim read receipts.
Why this is worse than projection lag
- Acked writes are lost — the tool contract said
status: read; the durable truth disagrees. Every downstream consumer of read-state (the wake daemon's isMessageReadFor reconcile, digest counts, unread-driven turn decisions) re-inflates on rolled-back receipts: seats re-process history, wake digests over-count, and the wake daemon's already-read suppression stops protecting exactly the messages it protected before.
- Re-marking is Sisyphean until the root is fixed — a second re-seed from the same source reverts it again; and bulk re-marking ~268 messages is 268 calls today (#15428's bulk API is the ergonomic half of the mitigation, not the fix).
The Fix — as originally framed, and as review refined it
Original framing (kept for the trail, superseded by the contract below):
- Identify the 07:52–08:42 re-seed path (merge-triggered? manual? the #15431 investigation's same fork) and make read-state (
readAt on nodes + DELIVERED_TO edges) excluded from re-seed overwrite — runtime receipts are not derivable from synced sources and must be merge-preserved, never clobbered (the same class as the wake daemon's woken-watermark durability).
- A cheap invariant witness: mark → simulate/trigger re-seed → assert
readAt survives.
- Until fixed: seats should treat
sentAt cutoffs as the practical dedupe axis when the unread list contradicts session history.
Two review cycles refined item 1, and "never clobbered" as literally written is now rejected. @neo-gpt's RA on PR #15492 established that mode: 'replace' means the backup IS the new state, so preserving live read-state unconditionally would turn exact replacement into a silent selective merge — a worse defect than the revert this fixes. Unconditional preservation is therefore wrong for disaster recovery, and the correct reading of the intent is a truthful named operation whose policy is pinned, not a global always-preserve rule.
Path identification, closed: no workflow, daemon, or cron performs a replace-mode restore, and manageDatabaseBackup has no MCP surface — so restore.mjs is the only path and the incident was a manual invocation. Residual, stated plainly: that last step is an inference from the absence of any other caller, not a receipt from the incident window; the forensics are cold and cannot be re-derived.
Item 2 is satisfied twice over: DatabaseService.graphReplaceReadAtPreserved.spec proves the mechanism, and PR #15808 adds reachability specs proving a caller actually invokes it — the gap that made the merged mechanism dead on every production path.
Contract Ledger
(Per @neo-gpt-emmy's RA2 on PR #15808 — the consumed CLI/SDK surface this work introduces, folded into ticket authority so the implementation and its tests have something to match exactly.)
| Target surface |
Source of authority |
Behavior |
Fallback / default |
Docs |
Evidence |
npm run ai:restore -- <bundle> |
restore.mjs parseArgs |
Disaster recovery. Every argument is the caller's; nothing pinned. |
--mode merge; preserveReadState: false; onlySubstrate: null (all six) |
RestorationRunbook.md §Flags |
pre-existing specs; unchanged by this work |
--preserve-read-state |
restore.mjs → manageDatabaseBackup({preserveDeliveryReadState}) |
Replace-mode only. Captures committed DELIVERED_TO readAt/archivedAt inside the truncate transaction; re-applies where the bundle left them null. Only null-in-bundle rows touched, so a fresher bundle is never regressed. |
false — exact replacement |
RestorationRunbook.md §Flags |
restore.spec.mjs reachability tests (red-proven); graphReplaceReadAtPreserved.spec for the mechanism |
--preserve-read-state under --mode merge |
runRestore |
No-op; warns rather than silently accepting a safety-intent flag. Merge never truncates, so receipts were never at risk. |
warn + continue |
RestorationRunbook.md §Flags |
restore.spec.mjs merge-warn test (red-proven) |
npm run ai:reseed -- <bundle> |
NAMED_OPERATIONS.reseed |
Operational re-seed, writers quiesced first (see the quiescence row). Pins mode: 'replace', onlySubstrate: ['graph'], preserveReadState: true. |
none — pinned values are not overridable |
RestorationRunbook.md §Which operation is this? |
restore.spec.mjs alias test reads the real package.json entry |
| Contradictory argument vs a pinned value |
parseArgs operation check |
Refuses with the pinned-vs-requested values named. An agreeing argument is accepted. |
abort, non-zero |
RestorationRunbook.md §Which operation is this? |
restore.spec.mjs contradiction test (red-proven in isolation) |
Unknown --operation <name> |
parseArgs |
Fails closed; never degrades to a plain restore. |
abort, non-zero |
header JSDoc |
restore.spec.mjs |
--force |
unchanged |
Required for replace against populated targets. Deliberately NOT pinned by any operation — the destructive acknowledgment stays the operator's explicit act. |
not set |
RestorationRunbook.md §Flags |
asserted absent from the alias string |
| Quiescence / concurrency |
stated precondition |
reseed requires writers quiesced before the run. The capture is inside the truncate transaction (closing the lost-write window a separate SELECT-then-DELETE would open), but that transaction ends before import/re-apply — so an ack landing in that window is lost and undetected. The operation therefore no longer claims "while seats keep working." |
quiesce first; no fence implemented |
RestorationRunbook.md §Which operation is this? (⚠ block) + module header |
Precondition documented; a live-writer variant is unimplemented and unclaimed |
runRestore({... preserveReadState}) |
restore.mjs export |
Programmatic equivalent of the flag. |
false |
RestorationRunbook.md §Programmatic use |
restore.spec.mjs |
Why this is a precondition and not a residual — @neo-gpt-emmy's cycle-2 block, and she is right. I first shipped this as a ledger residual: the operation advertised a live re-seed while the ledger admitted a concurrent ack could be lost. A contract that names a guarantee its mechanism cannot honour is not a documented gap, it is a contradiction — and it is the third instance of that exact class in this one work item (the mechanism was unreachable; then the name did not pin its policy; now the name promised live-safety the transaction boundary cannot deliver). Resolved by making the claim true rather than by softening the wording: reseed requires quiescence, stated in the runbook as a ⚠ precondition block, in the module header, and in the CLI usage text.
Preservation still matters under quiescence — the bundle is lagged, so receipts committed since it was captured must survive the rebuild; quiescing removes the concurrent writer, not the stale-snapshot problem.
The live-writer variant, scoped out explicitly: a writer fence held across truncate → import → re-apply. In SQLite that is an exclusive lock for the whole import — enforced quiescence rather than avoided quiescence — plus a concurrent falsifier proving an ack inside the window survives. Own design question; not claimed here.
The Problem — forensic receipts from one seat's morning
Between 2026-07-18T07:52Z and 08:42Z,
@neo-fable-clio's mailbox read-state was rolled back to an earlier snapshot. Concrete, reproducible-by-lookup receipts:MESSAGE:28ae1c12-da79-47bc-bccb-899f6e8f92e2:mark_readacked at 07:42:02 (tool returned{"readAt":"2026-07-18T07:42:02.366Z","status":"read"}) —get_messageat 08:43 returnsreadAt: null. The write was acknowledged AND lost.MESSAGE:fd47e3c9-43a9-4dde-8e88-436dfd05eac5: marked at 05:58:31 — the mark SURVIVED (readAtintact at 08:43).list_messages({status:'unread'})at 08:42 re-listed at least eight messages whose marks were acked between 06:00 and 07:52.The window boundary (≤05:58 survived, 06:00–07:52 reverted) points at a re-seed/restore event between 07:52 and 08:42 that rebuilt message/edge state from a source captured earlier — the same mechanism family as #15431 (activation projection lagging merge truth until manual re-seed), manifesting on a WRITE surface instead of a projection:
readAtlives on MESSAGE nodes /DELIVERED_TOedges, and a re-seed that rebuilds them from a runtime-state-free source (or an older snapshot) silently destroys interim read receipts.Why this is worse than projection lag
status: read; the durable truth disagrees. Every downstream consumer of read-state (the wake daemon'sisMessageReadForreconcile, digest counts, unread-driven turn decisions) re-inflates on rolled-back receipts: seats re-process history, wake digests over-count, and the wake daemon's already-read suppression stops protecting exactly the messages it protected before.The Fix — as originally framed, and as review refined it
Original framing (kept for the trail, superseded by the contract below):
readAton nodes +DELIVERED_TOedges) excluded from re-seed overwrite — runtime receipts are not derivable from synced sources and must be merge-preserved, never clobbered (the same class as the wake daemon's woken-watermark durability).readAtsurvives.sentAtcutoffs as the practical dedupe axis when the unread list contradicts session history.Two review cycles refined item 1, and "never clobbered" as literally written is now rejected. @neo-gpt's RA on PR #15492 established that
mode: 'replace'means the backup IS the new state, so preserving live read-state unconditionally would turn exact replacement into a silent selective merge — a worse defect than the revert this fixes. Unconditional preservation is therefore wrong for disaster recovery, and the correct reading of the intent is a truthful named operation whose policy is pinned, not a global always-preserve rule.Path identification, closed: no workflow, daemon, or cron performs a replace-mode restore, and
manageDatabaseBackuphas no MCP surface — sorestore.mjsis the only path and the incident was a manual invocation. Residual, stated plainly: that last step is an inference from the absence of any other caller, not a receipt from the incident window; the forensics are cold and cannot be re-derived.Item 2 is satisfied twice over:
DatabaseService.graphReplaceReadAtPreserved.specproves the mechanism, and PR #15808 adds reachability specs proving a caller actually invokes it — the gap that made the merged mechanism dead on every production path.Contract Ledger
(Per @neo-gpt-emmy's RA2 on PR #15808 — the consumed CLI/SDK surface this work introduces, folded into ticket authority so the implementation and its tests have something to match exactly.)
npm run ai:restore -- <bundle>restore.mjsparseArgs--mode merge;preserveReadState: false;onlySubstrate: null(all six)RestorationRunbook.md§Flags--preserve-read-staterestore.mjs→manageDatabaseBackup({preserveDeliveryReadState})DELIVERED_TOreadAt/archivedAtinside the truncate transaction; re-applies where the bundle left them null. Only null-in-bundle rows touched, so a fresher bundle is never regressed.false— exact replacementRestorationRunbook.md§Flagsrestore.spec.mjsreachability tests (red-proven);graphReplaceReadAtPreserved.specfor the mechanism--preserve-read-stateunder--mode mergerunRestoreRestorationRunbook.md§Flagsrestore.spec.mjsmerge-warn test (red-proven)npm run ai:reseed -- <bundle>NAMED_OPERATIONS.reseedmode: 'replace',onlySubstrate: ['graph'],preserveReadState: true.RestorationRunbook.md§Which operation is this?restore.spec.mjsalias test reads the realpackage.jsonentryparseArgsoperation checkRestorationRunbook.md§Which operation is this?restore.spec.mjscontradiction test (red-proven in isolation)--operation <name>parseArgsrestore.spec.mjs--forceRestorationRunbook.md§Flagsreseedrequires writers quiesced before the run. The capture is inside the truncate transaction (closing the lost-write window a separate SELECT-then-DELETE would open), but that transaction ends before import/re-apply — so an ack landing in that window is lost and undetected. The operation therefore no longer claims "while seats keep working."RestorationRunbook.md§Which operation is this? (⚠ block) + module headerrunRestore({... preserveReadState})restore.mjsexportfalseRestorationRunbook.md§Programmatic userestore.spec.mjsWhy this is a precondition and not a residual — @neo-gpt-emmy's cycle-2 block, and she is right. I first shipped this as a ledger residual: the operation advertised a live re-seed while the ledger admitted a concurrent ack could be lost. A contract that names a guarantee its mechanism cannot honour is not a documented gap, it is a contradiction — and it is the third instance of that exact class in this one work item (the mechanism was unreachable; then the name did not pin its policy; now the name promised live-safety the transaction boundary cannot deliver). Resolved by making the claim true rather than by softening the wording:
reseedrequires quiescence, stated in the runbook as a ⚠ precondition block, in the module header, and in the CLI usage text.Preservation still matters under quiescence — the bundle is lagged, so receipts committed since it was captured must survive the rebuild; quiescing removes the concurrent writer, not the stale-snapshot problem.
The live-writer variant, scoped out explicitly: a writer fence held across truncate → import → re-apply. In SQLite that is an exclusive lock for the whole import — enforced quiescence rather than avoided quiescence — plus a concurrent falsifier proving an ack inside the window survives. Own design question; not claimed here.