Premise
Scoped out of #15322 (mark-path) at PR #15357 review RA-3, with @neo-opus-ada's root-cause analysis. This is the read-path half: listMessages / countMessages do not repair a broadcast whose entire DELIVERED_TO cohort was lost, because the gate that triggers view-scoped repair cannot see the loss.
The gap (live-verified on dev)
ai/services/memory-core/MailboxService.mjs:850 — hasMailboxGraphProjectionGap():
return (row?.messageCount ?? 0) < projectedCount ||
(row?.sentByCount ?? 0) < projectedCount ||
(row?.sentToCount ?? 0) < projectedCount;It compares messageCount, sentByCount, sentToCount against the WAL projectedCount. There is no DELIVERED_TO term. A broadcast that lost its whole per-recipient delivery cohort still has its MESSAGE node, its SENT_BY, and its SENT_TO → AGENT:* — so all three counts match projectedCount, the gate returns false, and:
MailboxService.mjs:1658 — if (!idFilter && !await hasMailboxGraphProjectionGap()) return summary; — the view-scoped repair returns early at scanned: 0. Every listMessages / countMessages over that view leaves the cohort unrepaired.
Consequence: the pure-read case — a recipient lists a cohort-damaged broadcast with no prior mark — never self-heals. (#15322's mark-path fix covers the case where someone marks: repairMessageGraphIntegrity({ids}) rebuilds the whole cohort. It does not cover a read with no mark, because reads route through this blind gate.)
The fix — and the trap in the obvious version
Add a DELIVERED_TO-aware term. But not deliveredToCount < projectedCount: projectedCount counts all messages while DISTINCT DELIVERED_TO source counts broadcasts only, so a single DM makes deliveredToCount < projectedCount permanently true → a full WAL scan on every list. (Flagged derived-not-measured by @neo-opus-ada; do not ship it.)
The precise predicate is a broadcast with zero delivery rows:
SELECT COUNT(*) FROM Edges b
WHERE b.source LIKE 'MESSAGE:%' AND b.type = 'SENT_TO' AND b.target = 'AGENT:*'
AND NOT EXISTS (SELECT 1 FROM Edges d WHERE d.source = b.source AND d.type = 'DELIVERED_TO')
Scope
In: the total cohort-loss (silent) case — a broadcast with zero delivery rows makes hasMailboxGraphProjectionGap true, so list/count repair it.
Out (surface, don't bundle): partial cohort loss (some recipients' edges present, one missing). That currently throws Unauthorized on the missing recipient — loud, not silent — and is a different disposition. @neo-opus-ada scoped it out; keep it out unless a witness shows it's silent too.
Acceptance Criteria
Avoided Traps
- Do not use
deliveredToCount < projectedCount — false-positives on any DM; forces a full WAL scan every list.
- Do not bundle partial-cohort loss — different symptom (loud
Unauthorized), different disposition.
- Do not assert green from a local run — this spec class can't execute locally (#15364); a red-proof that was never observed red is not a proof (CI is the oracle).
Retrieval Hint: "MailboxService hasMailboxGraphProjectionGap DELIVERED_TO read gate broadcast list countMessages self-heal cohort loss zero delivery rows 15322 read-path half"
Premise
Scoped out of #15322 (mark-path) at PR #15357 review RA-3, with @neo-opus-ada's root-cause analysis. This is the read-path half:
listMessages/countMessagesdo not repair a broadcast whose entireDELIVERED_TOcohort was lost, because the gate that triggers view-scoped repair cannot see the loss.The gap (live-verified on
dev)ai/services/memory-core/MailboxService.mjs:850—hasMailboxGraphProjectionGap():return (row?.messageCount ?? 0) < projectedCount || (row?.sentByCount ?? 0) < projectedCount || (row?.sentToCount ?? 0) < projectedCount;It compares
messageCount,sentByCount,sentToCountagainst the WALprojectedCount. There is noDELIVERED_TOterm. A broadcast that lost its whole per-recipient delivery cohort still has itsMESSAGEnode, itsSENT_BY, and itsSENT_TO → AGENT:*— so all three counts matchprojectedCount, the gate returnsfalse, and:MailboxService.mjs:1658—if (!idFilter && !await hasMailboxGraphProjectionGap()) return summary;— the view-scoped repair returns early atscanned: 0. EverylistMessages/countMessagesover that view leaves the cohort unrepaired.Consequence: the pure-read case — a recipient lists a cohort-damaged broadcast with no prior mark — never self-heals. (#15322's mark-path fix covers the case where someone marks:
repairMessageGraphIntegrity({ids})rebuilds the whole cohort. It does not cover a read with no mark, because reads route through this blind gate.)The fix — and the trap in the obvious version
Add a
DELIVERED_TO-aware term. But notdeliveredToCount < projectedCount:projectedCountcounts all messages whileDISTINCT DELIVERED_TO sourcecounts broadcasts only, so a single DM makesdeliveredToCount < projectedCountpermanently true → a full WAL scan on every list. (Flagged derived-not-measured by @neo-opus-ada; do not ship it.)The precise predicate is a broadcast with zero delivery rows:
SELECT COUNT(*) FROM Edges b WHERE b.source LIKE 'MESSAGE:%' AND b.type = 'SENT_TO' AND b.target = 'AGENT:*' AND NOT EXISTS (SELECT 1 FROM Edges d WHERE d.source = b.source AND d.type = 'DELIVERED_TO')Scope
In: the total cohort-loss (silent) case — a broadcast with zero delivery rows makes
hasMailboxGraphProjectionGaptrue, so list/count repair it.Out (surface, don't bundle): partial cohort loss (some recipients' edges present, one missing). That currently throws
Unauthorizedon the missing recipient — loud, not silent — and is a different disposition. @neo-opus-ada scoped it out; keep it out unless a witness shows it's silent too.Acceptance Criteria
hasMailboxGraphProjectionGapreturns true when a broadcast hasSENT_TO → AGENT:*but zeroDELIVERED_TOrows.MailboxServicespecs die on theNeo.ai.Configcollision, #15364).markRead(the #15322 mark-path fix) or DM read/count.Avoided Traps
deliveredToCount < projectedCount— false-positives on any DM; forces a full WAL scan every list.Unauthorized), different disposition.Retrieval Hint: "MailboxService hasMailboxGraphProjectionGap DELIVERED_TO read gate broadcast list countMessages self-heal cohort loss zero delivery rows 15322 read-path half"