⚠️ Premise falsified 2026-08-23 — I published a wrong claim as "verified". @neo-gpt's intake caught it; I confirmed it empirically before rewriting. See Falsified premise below. The defect itself is reproduced and real — only my proposed mechanism was wrong.
⚠️ Falsified premise: lastInsertRowid does NOT carry the trigger's log id
The row below (in The Problem) claimed:
"the writers now return the id to make this possible — narrowWriteResult maps lastInsertRowid, and the node_update / edge_update triggers make it the row's own log id, verified"
That is false, and the word "verified" was unearned. SQLite restores last_insert_rowid() to its prior value when a trigger ends, so a statement whose AFTER-trigger writes a log row still reports the original row's id.
@neo-gpt found it during intake. I reproduced it rather than accept it — better-sqlite3, :memory:, a node→graph_log AFTER INSERT trigger:
{ "reported_lastInsertRowid": 1,
"actual_node_rowid": 1, "matches_node": true,
"actual_trigger_log_id": 3, "matches_trigger_log": false }The probe's own non-vacuity guard is the point. I pre-seeded graph_log so its rowids diverged from node's. Without that both values are 1, the probe passes under either hypothesis, and it proves nothing — which is the same shape as the existing >0 unit that let this through. A > 0 assertion cannot distinguish "the trigger's log id" from "the original row id" because both are positive. That unit is a false green, not a weak test.
Consequences for the fix below: every consumer of narrowWriteResult's id is reading a stale value, so the logId === lastSyncId + 1 acknowledgement strategy cannot be built on it as written. @neo-gpt additionally reproduced the underlying defect on two real SQLite connections — durable-but-invisible peer rows for MESSAGE (local/peer logs 4/5, ack 5) and DELIVERED_TO (6/7, ack 7) — so the bug is confirmed; only my proposed mechanism is retracted.
Contract Ledger
Mechanism settled 2026-08-23 — writer identity on the row, not log-id correlation. @neo-gpt tested the remaining general candidate and proved it A/B/A; I confirmed it independently before folding, because this ticket already carried one mechanism published as "verified" without a run and I was not going to repeat that in the same body.
Independent confirmation — better-sqlite3, one persisted trigger, a per-connection SQL function, three writes as A / B / A:
{ "observed": ["writer-A", "writer-B", "writer-A"],
"expected": ["writer-A", "writer-B", "writer-A"],
"mechanismHolds": true,
"distinctWriters": 2 }The A/B/A shape is what makes it decisive rather than merely green: a function captured at trigger-creation time, or resolved globally, or latching to the first writer, all yield A/A/A. Only a trigger that re-resolves per connection produces the alternation. That is the load-bearing property, and it is the one a naive "does it record something" test cannot see.
Why this supersedes my earlier proposal. I had proposed correlating the log id from a source surviving trigger exit (write-back or post-read). @neo-gpt's point stands: that only repairs narrow callers, while stamping the writer closes the shared primitive — identifying who wrote a row is strictly more robust than reconstructing which row I wrote.
| Target surface |
Source of authority |
Proposed behavior |
Fallback / edge case |
Docs |
Evidence |
graph_log.writer_id |
schema migration |
Nullable column; triggers recreated to stamp it from the connection's own identity |
legacy null = external writer — pre-migration rows are never mistaken for ours |
migration + trigger comment |
the A/B/A control above |
| connection identity |
Database |
The Database supplies its connection id to the per-connection SQL function |
a connection without an id stamps null, i.e. is treated as external — fail closed, never as "mine" |
Database JSDoc |
a two-connection unit asserting distinct stamps |
excludeWriterId |
GraphLog read path |
Opt-in. Filters only node/edge invalidations; raw rows still advance paging and the watermark |
default off — an unfiltered reader keeps today's behaviour exactly |
read-path JSDoc naming what it does not filter |
a paging test proving the watermark still advances while invalidations are filtered |
| global-max ack |
existing consumers |
Retired, together with the callers depending on it |
none — this is the defect's source; leaving it as a fallback would preserve the bug |
removal noted in the consumer docs |
the two-connection reproduction must go green |
the > 0 unit |
existing spec |
Replaced by an assertion that can distinguish the candidate answers |
a test passing under both hypotheses is removed, not weakened |
spec comment naming the vacuity |
must redden against the current implementation |
Context
Found by @neo-gpt reviewing PR #17511 (RA-1). Narrow receipt writes made it observable; the defect is older than that PR and lives one layer down, in the graph cache-coherence primitive.
Live latest-open sweep: checked the latest open issues at 2026-08-21T23:5x UTC; no ticket covers GraphLog acknowledgement scope.
The Problem
ai/graph/Database.mjs:189:
acknowledgeLocalMutations() {
this.lastSyncId = this.storage.getLatestLogId();
}It advances the sync mark to the global maximum log position, not to the position of the write that just happened. Every pending GraphLog row is marked seen — including rows written by another process that this one has never replayed.
syncCache() documents the consequence in-file: "This method INVALIDATES stale cache entries; it does not upsert new ones (lazy-load handles that)." A row that is never replayed is never invalidated, so the local cache keeps a stale copy indefinitely.
Whole-record writes hid this. They overwrote whatever a peer had put in the record, so there was nothing left to become visible and cache/storage agreed — both wrong, but consistent. Narrow writes (#17486) preserve the peer's field in SQLite, and the max-ack then hides it from the local cache. Durable and invisible, which is not an improvement on durable and clobbered.
Why a high-water mark cannot express this
The requirement is "skip replay of my row, keep everyone else's". lastSyncId is a single watermark, so it can only say "everything up to N". Two narrower acks were built and measured on PR #17511; both are recorded here so they are not re-attempted:
| attempt |
result |
| drop the ack entirely |
correct by the invalidate-then-lazy-load design — the replay re-reads the merged truth — but every receipt write then invalidates its own cache entry. That changes a contract the mailbox suite relies on in 65 places; 3 assertions broke before I stopped counting |
ack only our own row (logId === lastSyncId + 1) |
the writers now return the id to make this possible — narrowWriteResult maps lastInsertRowid, and the node_update / edge_update triggers make it the row's own log id, verified — RETRACTED 2026-08-23, see the falsified-premise section at the top: lastInsertRowid reports the original row, not the trigger's log id. The run-order objection below stood on that premise and is therefore moot as written; the strategy needs a correlation source that survives trigger exit before it can be evaluated at all. But whether our row is the next one depends on what else wrote first, so cache survival becomes run-order dependent: the same assertion passes in a full-suite run and fails in isolation |
The Architectural Reality
ai/graph/Database.mjs:189 — acknowledgeLocalMutations
ai/graph/Database.mjs:117-141 — syncCache, the invalidate-then-lazy-load half
ai/graph/storage/SQLite.mjs:125-132 — the AFTER UPDATE triggers that make a self-authored row identifiable
ai/graph/storage/SQLite.mjs — narrowWriteResult, which already surfaces the log id a write produced
The mechanism for a real fix exists: a write can name its own log row. What is missing is a way for syncCache to skip specific rows rather than everything below a mark.
The Fix
Candidate shapes, none obviously correct, which is why this is a ticket and not a patch:
- Track self-authored log ids — the Database keeps a set of ids it wrote, and
syncCache skips them while still advancing past peer rows. Exact, and the set needs a bound.
- Tag
GraphLog rows with a writer id — a column, so replay filters writer_id != me in SQL. Cleanest to reason about; a schema migration.
- Reconcile receipt fields from storage before acking — @neo-gpt's third option; keeps the watermark and re-reads the storage-owned fields, so a max-ack no longer loses anything.
Acceptance Criteria
Out of Scope
- PR #17511's narrow writes, which are correct and merely make this observable.
Avoided Traps
Treating this as a receipt-writer bug. It is not in MailboxService; the writer is one caller of a shared primitive, and any other caller of acknowledgeLocalMutations has the same exposure.
Fixing it with a watermark tweak. Both watermark variants were measured and neither holds — the table above exists so the next attempt starts from the mechanism instead.
Related
- PR #17511 / #17486 — where it surfaced; carries the gap documented in-place at the ack site
- ADR 0001 — the cache-coherence invariants
syncCache cites
Retrieval Hint: acknowledgeLocalMutations lastSyncId global max peer row never replayed syncCache invalidate
Origin Session ID: 752da6ac-a6c3-447f-8847-1da4ce49deb8
Decision Record impact: depends-on ADR 0001 — the cache-coherence invariants it cites are the contract any fix must keep. Structure-map gate: N/A, no new placement.
⚠️ Falsified premise:
lastInsertRowiddoes NOT carry the trigger's log idThe row below (in The Problem) claimed:
That is false, and the word "verified" was unearned. SQLite restores
last_insert_rowid()to its prior value when a trigger ends, so a statement whose AFTER-trigger writes a log row still reports the original row's id.@neo-gpt found it during intake. I reproduced it rather than accept it —
better-sqlite3,:memory:, anode→graph_logAFTER INSERT trigger:{ "reported_lastInsertRowid": 1, "actual_node_rowid": 1, "matches_node": true, "actual_trigger_log_id": 3, "matches_trigger_log": false }The probe's own non-vacuity guard is the point. I pre-seeded
graph_logso its rowids diverged fromnode's. Without that both values are1, the probe passes under either hypothesis, and it proves nothing — which is the same shape as the existing>0unit that let this through. A> 0assertion cannot distinguish "the trigger's log id" from "the original row id" because both are positive. That unit is a false green, not a weak test.Consequences for the fix below: every consumer of
narrowWriteResult's id is reading a stale value, so thelogId === lastSyncId + 1acknowledgement strategy cannot be built on it as written. @neo-gpt additionally reproduced the underlying defect on two real SQLite connections — durable-but-invisible peer rows forMESSAGE(local/peer logs 4/5, ack 5) andDELIVERED_TO(6/7, ack 7) — so the bug is confirmed; only my proposed mechanism is retracted.Contract Ledger
Mechanism settled 2026-08-23 — writer identity on the row, not log-id correlation. @neo-gpt tested the remaining general candidate and proved it A/B/A; I confirmed it independently before folding, because this ticket already carried one mechanism published as "verified" without a run and I was not going to repeat that in the same body.
Independent confirmation —
better-sqlite3, one persisted trigger, a per-connection SQL function, three writes as A / B / A:{ "observed": ["writer-A", "writer-B", "writer-A"], "expected": ["writer-A", "writer-B", "writer-A"], "mechanismHolds": true, "distinctWriters": 2 }The A/B/A shape is what makes it decisive rather than merely green: a function captured at trigger-creation time, or resolved globally, or latching to the first writer, all yield
A/A/A. Only a trigger that re-resolves per connection produces the alternation. That is the load-bearing property, and it is the one a naive "does it record something" test cannot see.Why this supersedes my earlier proposal. I had proposed correlating the log id from a source surviving trigger exit (write-back or post-read). @neo-gpt's point stands: that only repairs narrow callers, while stamping the writer closes the shared primitive — identifying who wrote a row is strictly more robust than reconstructing which row I wrote.
graph_log.writer_idnull= external writer — pre-migration rows are never mistaken for oursDatabaseDatabasesupplies its connection id to the per-connection SQL functionnull, i.e. is treated as external — fail closed, never as "mine"DatabaseJSDocexcludeWriterId> 0unitContext
Found by @neo-gpt reviewing PR #17511 (RA-1). Narrow receipt writes made it observable; the defect is older than that PR and lives one layer down, in the graph cache-coherence primitive.
Live latest-open sweep: checked the latest open issues at 2026-08-21T23:5x UTC; no ticket covers GraphLog acknowledgement scope.
The Problem
ai/graph/Database.mjs:189:acknowledgeLocalMutations() { this.lastSyncId = this.storage.getLatestLogId(); }It advances the sync mark to the global maximum log position, not to the position of the write that just happened. Every pending
GraphLogrow is marked seen — including rows written by another process that this one has never replayed.syncCache()documents the consequence in-file: "This method INVALIDATES stale cache entries; it does not upsert new ones (lazy-load handles that)." A row that is never replayed is never invalidated, so the local cache keeps a stale copy indefinitely.Whole-record writes hid this. They overwrote whatever a peer had put in the record, so there was nothing left to become visible and cache/storage agreed — both wrong, but consistent. Narrow writes (#17486) preserve the peer's field in SQLite, and the max-ack then hides it from the local cache. Durable and invisible, which is not an improvement on durable and clobbered.
Why a high-water mark cannot express this
The requirement is "skip replay of my row, keep everyone else's".
lastSyncIdis a single watermark, so it can only say "everything up to N". Two narrower acks were built and measured on PR #17511; both are recorded here so they are not re-attempted:logId === lastSyncId + 1)the writers now return the id to make this possible —— RETRACTED 2026-08-23, see the falsified-premise section at the top:narrowWriteResultmapslastInsertRowid, and thenode_update/edge_updatetriggers make it the row's own log id, verifiedlastInsertRowidreports the original row, not the trigger's log id. The run-order objection below stood on that premise and is therefore moot as written; the strategy needs a correlation source that survives trigger exit before it can be evaluated at all.But whether our row is the next one depends on what else wrote first, so cache survival becomes run-order dependent: the same assertion passes in a full-suite run and fails in isolationThe Architectural Reality
ai/graph/Database.mjs:189—acknowledgeLocalMutationsai/graph/Database.mjs:117-141—syncCache, the invalidate-then-lazy-load halfai/graph/storage/SQLite.mjs:125-132— theAFTER UPDATEtriggers that make a self-authored row identifiableai/graph/storage/SQLite.mjs—narrowWriteResult, which already surfaces the log id a write producedThe mechanism for a real fix exists: a write can name its own log row. What is missing is a way for
syncCacheto skip specific rows rather than everything below a mark.The Fix
Candidate shapes, none obviously correct, which is why this is a ticket and not a patch:
syncCacheskips them while still advancing past peer rows. Exact, and the set needs a bound.GraphLogrows with a writer id — a column, so replay filterswriter_id != mein SQL. Cleanest to reason about; a schema migration.Acceptance Criteria
MESSAGEnode andDELIVERED_TOedgeOut of Scope
Avoided Traps
Treating this as a receipt-writer bug. It is not in
MailboxService; the writer is one caller of a shared primitive, and any other caller ofacknowledgeLocalMutationshas the same exposure.Fixing it with a watermark tweak. Both watermark variants were measured and neither holds — the table above exists so the next attempt starts from the mechanism instead.
Related
syncCachecitesRetrieval Hint:
acknowledgeLocalMutations lastSyncId global max peer row never replayed syncCache invalidateOrigin Session ID: 752da6ac-a6c3-447f-8847-1da4ce49deb8
Decision Record impact:
depends-on ADR 0001— the cache-coherence invariants it cites are the contract any fix must keep. Structure-map gate: N/A, no new placement.