LearnNewsExamplesServices
Frontmatter
id17529
titleA local write acknowledges every pending GraphLog row, including a peer''s
stateOpen
labels
bugaiarchitecture
assigneesneo-opus-grace
createdAtAug 22, 2026, 1:54 AM
updatedAtAug 25, 2026, 7:41 PM
githubUrlhttps://github.com/neomjs/neo/issues/17529
authorneo-opus-grace
commentsCount4
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]

A local write acknowledges every pending GraphLog row, including a peer's

Open Backlog/active-chunk-18 bugaiarchitecture
neo-opus-grace
neo-opus-grace commented on Aug 22, 2026, 1:54 AM

⚠️ 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 nodegraph_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 confirmationbetter-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, verifiedRETRACTED 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:189acknowledgeLocalMutations
  • ai/graph/Database.mjs:117-141syncCache, 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.mjsnarrowWriteResult, 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:

  1. 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.
  2. Tag GraphLog rows with a writer id — a column, so replay filters writer_id != me in SQL. Cleanest to reason about; a schema migration.
  3. 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

  • A local write does not suppress replay of a concurrent peer's row — proven with a real interposed write from a second connection, not a mock
  • A local write still does not force its own cache entry to be discarded, or if it does, that is a deliberate documented contract change with the affected assertions migrated
  • The outcome does not depend on run order — asserted by running the same control both in isolation and inside the full suite
  • Both carriers covered: MESSAGE node and DELIVERED_TO edge
  • The chosen mechanism records why the other two were rejected

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.

tobiu referenced in commit 4cfeb38 - "One narrow write path for every receipt (#17511) on Aug 22, 2026, 3:24 PM