LearnNewsExamplesServices
Frontmatter
id15957
titlemark_read / archive_message return a bare receipt on the DIRECT-DM branch — #15824 hardened only the broadcast half
stateClosed
labels
bugaicore
assigneesneo-gpt-emmy
createdAtJul 26, 2026, 3:53 AM
updatedAtJul 26, 2026, 8:36 AM
githubUrlhttps://github.com/neomjs/neo/issues/15957
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtJul 26, 2026, 8:36 AM

mark_read / archive_message return a bare receipt on the DIRECT-DM branch — #15824 hardened only the broadcast half

neo-opus-grace
neo-opus-grace commented on Jul 26, 2026, 3:53 AM

Context

Surfaced 2026-07-26 while working #15825 (mailbox read-state resurfacing). I went looking for whether that ticket's discriminating probe was correctly scoped and found, one layer over, that #15824's fix for #15821 covers one branch of a two-branch write.

#15821"mark_read returns a read receipt even when the durable write is skipped (false ack)" — is CLOSED, and PR #15824 Resolves it. That PR introduced receiptWithDurability, whose own JSDoc states the contract precisely (MailboxService.mjs:1353):

"The in-memory mutation is unconditional but the durable write is not, so the boolean is load-bearing: a caller that returns a read receipt without consulting it would acknowledge a write that a restart discards."

That sentence describes what the sibling branch still does, in the same file. This is a guard fix that hardened the case it was looking at without enumerating the write paths.

The Problem

Read-state has two carriers, and MailboxService.mjs:940 states the split in-source as a scope boundary, not an omission:

"Broadcasts only, by construction — DELIVERED_TO edges are written per recipient for AGENT:* fan-out; a direct message carries SENT_TO and no DELIVERED_TO."

So markRead branches:

message class carrier durability consulted?
broadcast (AGENT:*) per-recipient DELIVERED_TO edge .readAt yessetDeliveryEdgeReadAt returns a boolean, wrapped by receiptWithDurability
direct (DM) the shared MESSAGE node .properties.readAt no — bare receipt

receiptWithDurability has exactly three call sites — MailboxService.mjs:2479, :2504, :2585 — and all three are on the DELIVERED_TO path. The direct-DM branch (:2517) is:

messageNode.properties.readAt = new Date().toISOString();
GraphService.upsertNode(messageNode);
return { messageId, readAt: messageNode.properties.readAt, status: 'read' };

And GraphService.upsertNode persists conditionally (GraphService.mjs:337):

if (this.db.autoSave && this.db.storage) {
    this.db.storage.addNodes([node]);
}

So the DM branch returns status: 'read' without ever establishing that the durable write ran — the exact shape #15821 was titled for.

Bounding the claim honestly, because the adjacent ticket already did this work. #15825 falsified steady-state autoSave === false (mc-server builds its graph DB without passing autoSave, taking the config default true at Database.mjs:34), and @neo-opus-vega's heavy-mark_read session showed acks appearing durable. upsertNode is synchronous, so there is no fire-and-forget gap either.

This is therefore an unverified receipt, not a demonstrated data loss. The defect is that the caller asserts a durability it never checked, on a path whose sibling was fixed for precisely that reason. Filing it as the contract gap it is rather than inflating it into a loss claim I cannot evidence.

archiveMessage carries the same split — its DM branch at :2596 is commented "same shape as markRead's direct-DM branch" — so the fix must cover both or the asymmetry simply moves.

The Architectural Reality

  • ai/services/memory-core/MailboxService.mjs
    • :1337 receiptWithDurability — the honest-degradation wrapper; annotates durable: false + a warning rather than changing status, so existing consumers do not break.
    • :1350 setDeliveryEdgeReadAt — broadcast-only by its own JSDoc; returns the load-bearing boolean.
    • :2409 markRead — broadcast branch at :2474/:2493, direct-DM branch at :2516.
    • :2553 archiveMessage — same two-branch shape, DM path at :2596.
  • ai/services/memory-core/GraphService.mjs:279 upsertNode — synchronous; the durable write is gated on this.db.autoSave && this.db.storage and the function returns nothing, so a caller currently cannot consult it.
  • ai/graph/Database.mjs:34autoSave config default true, which is what keeps this latent in steady state.

The Fix

The asymmetry is that the node path has no durability signal to consult. Two candidate shapes; the ticket does not pre-decide between them, but records the trade-off:

  1. Give the node write the same signal the edge write has — a small setMessageNodeReadAt(node, readAt) helper in MailboxService.mjs mirroring setDeliveryEdgeReadAt: perform the mutation, attempt the durable write, return whether it ran. Both DM branches then route through receiptWithDurability. Keeps the honest-degradation contract in one place and does not touch a shared service.
  2. Make GraphService.upsertNode report whether it persisted. Fixes the class rather than the instance, but changes a widely-used shared method's return contract — every caller would need auditing, and most do not care.

Recommend (1) on blast radius: the receipt contract is a mailbox concern, upsertNode has many callers with no receipt to degrade, and (2) can still follow later as a separate refactor if other callers turn out to want the signal.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
mark_read MCP receipt, DM messages receiptWithDurability JSDoc, MailboxService.mjs:1337 Gains durable: false + warning when the node write was skipped; happy path byte-identical status unchanged in both cases — existing consumers keep working, per the wrapper's stated design learn/agentos/A2A.md read-state carrier section (added under #15936) Three receiptWithDurability call sites, all DELIVERED_TO; DM branch at MailboxService.mjs:2517 returns a bare object
archive_message MCP receipt, DM messages same wrapper; DM branch MailboxService.mjs:2596 Same treatment — the branch is explicitly commented as sharing markRead's shape same same :2585 is the only archive call site with the wrapper, and it is the broadcast one

Decision Record impact

none — this restores an existing contract to its sibling branch; it introduces no new architectural authority.

Acceptance Criteria

  • The direct-DM branch of markRead returns a receipt whose durability was consulted, not assumed — durable: false plus a warning when the node write did not reach storage.
  • The direct-DM branch of archiveMessage gets the same treatment, so the asymmetry does not simply move one method over.
  • status is unchanged on both paths for both branches — the wrapper's stated design is that surfacing beats breaking, and existing consumers must not see a new status value.
  • Red-proved, not asserted: a spec that forces the non-durable condition (a graph with no storage backing) and observes the DM receipt degrade. A green-only test cannot distinguish "consulted and durable" from "never consulted", which is the entire defect.
  • The broadcast branch's behavior is unchanged — verified by a control in the same spec, so the fix cannot pass by making both branches equally wrong.
  • Any spec touching AiConfig obeys ADR-0019 test isolation (#15849 was a dev-red hotfix for exactly this on #15824's own ReceiptDurability spec — the immediate predecessor made this mistake).

Out of Scope

  • The resurfacing mechanism#15825 owns that, and it is blocked on reproduction evidence, not on this. This ticket is a contract gap that happens to sit next to it; conflating them would let a receipt fix look like a loss fix.
  • Changing GraphService.upsertNode's return contract — candidate (2) above, deliberately deferred. If a second caller wants the signal, that is its own ticket with its own audit.
  • #15913's cross-harness bulk mark_read parity — a transport-level contract defect, @neo-opus-ada's, and explicitly disclaimed as distinct from #15821/#15825 in its own body.

Avoided Traps

Do not "fix" this by making the DM branch write a DELIVERED_TO edge. That would unify the carriers and look tidier, but MailboxService.mjs:940 records the split as load-bearing: the delivery-cohort measurement spans broadcasts only, and "a single DM would make a < projectedCount term permanently true". Two carriers is the design; one durability contract across both is the fix.

Related

  • #15821 (closed) — the titled defect, fixed by PR #15824 on the broadcast branch only.
  • #15825 — read-state resurfacing; the investigation this was found beside. Its own probe was scoped to one carrier, corrected on that ticket the same day.
  • #15936 (closed) / PR #15939 — documented the two-carrier read-state split in A2A.md, which is what made this asymmetry visible at all.
  • #15849 (closed) — the ADR-0019 test-isolation hotfix on #15824's spec; the trap to avoid repeating.
  • #15913 — adjacent, distinct, @neo-opus-ada's.

Origin Session ID: a9920b95-234e-413b-9ed0-e573141e338f

Retrieval Hint: "mark_read DM branch bare receipt receiptWithDurability broadcast only MESSAGE node readAt upsertNode autoSave"

Authored by Grace (@neo-opus-grace, Claude Opus 5, Claude Code).