Context
The plane has zero backups. Live Memory Core healthcheck at 2026-08-04T20:52:18Z:
"backup": {"lastSuccessful": null, "lastCompleted": null, "count": 0, "unusableCount": 0, "unverifiedCount": 0}This was found while verifying the Knowledge Base restore @neo-opus-grace executed today (61,206 chunks recovered, per-collection verified). The corpus came back and was immediately unprotected — the backup layer had been failing for hours while reporting a data-integrity error it manufactured itself.
Three export paths own this concern. All three mis-state what they captured, in two opposite directions.
The Problem
Direction A — over-strict: a collection that GREW is destroyed as a partial export
Observed, plane-wide:
PARTIAL_COLLECTION_EXPORT: neo-agent-memory exported 32272/32271 records
That is one row more than expected. expected is a count snapshot taken before the streaming pass, and the guard used strict inequality in both directions — so a single live agent writing one memory mid-export aborted the entire bundle. The collection is provably still growing: the same healthcheck reports neo-agent-memory at 32,358, up from 32,271 in the failure message.
The comparison has three outcomes, not two:
| counts |
meaning |
correct action |
exported < expected |
rows the snapshot knew about are missing |
abort — genuine loss |
exported > expected |
the source grew during streaming |
keep the bundle, record the caveat |
exported === expected |
clean |
keep |
A grown export must not be recorded as a clean capture either: neither path holds a single-instant read. The vector path walks by offset, so an insert landing in an already-walked page shifts later rows — a concurrent write can skip one row while duplicating another and still finish high. Complete-or-better, with a caveat that belongs in the receipt.
Direction B — under-strict: the KB export cannot detect loss at all
Knowledge_DatabaseService#exportCollection never counted what it wrote. It returned count — the pre-pass snapshot — and logged it as the exported total. Its per-id rescue path dropped corrupted vectors with logger.error and no tally.
This is the severe one, because of who consumes that number. From exportDatabase's own JSDoc:
count is the numeric export row count, consumed by the backup orchestrator's verifyBundleIntegrity for KB row-count parity
The bundle integrity verifier was comparing the snapshot against itself. A KB export that silently dropped rows in the rescue path passed parity verification. This is the instrument that certified the backup set the restore depended on.
How each site was found — the population was never the failure name
The error named neo-agent-memory, so the Memory Core vector path was the obvious site. The second (native-graph) was found only by grepping for the shape rather than the error that fired. The third (Knowledge Base) was found only by asking who consumes the returned count. Fixing the site that threw would have left the corpus-protecting path untouched.
The Architectural Reality
| # |
Site |
Direction |
Mechanism |
| 1 |
ai/services/memory-core/DatabaseService.mjs #exportCollection |
A |
offset-paged Chroma read; count snapshot before the loop |
| 2 |
ai/services/memory-core/DatabaseService.mjs #exportGraph |
A |
two count(*) statements, then two iterate() scans |
| 3 |
ai/services/knowledge-base/DatabaseService.mjs #exportCollection |
B |
deliberate mirror of site 1; returns the snapshot, no guard |
Site 2's window is wider than it looks and does not require another process. Between the counts and the scans the method does await import('fs-extra'), await import('path') and await fs.ensureDir(...). better-sqlite3 blocks writes only on the same connection during iteration — a second connection (another daemon writing one memory) lands in that gap freely.
Reproduced against a real WAL database, one concurrent insert from a second connection:
expected=100 exported=101 (one concurrent insert)
SHIPPED guard (exported !== expected) -> destroys backup: true
REPAIRED guard (exported < expected) -> destroys backup: false
negative control — genuine loss (98/101) still aborts under (<): true
Site 3 is annotated in-source as "duplicated deliberately to keep each service's backup logic locally discoverable". That intent is respected below — the predicate is shared, the per-path accuracy caveat stays local.
The Fix
New pure helper ai/services/memory-core/helpers/exportCompleteness.mjs (sibling precedent: vectorWriteInvariant.mjs, vectorJsonlSourceValidation.mjs, graphJsonlImport.mjs in the same folder; the KB service already imports two of them):
EXPORT_COMPLETENESS — frozen verdicts complete / grew / indeterminate / partial
classifyExportCompleteness(exported, expected) — pure three-way classification
recordExportGrowth(stats) — stamps grewDuringExport + growthDelta onto the receipt
indeterminate exists so a non-finite or missing count can never certify a bundle. Defaulting it to complete would let an absent measurement vouch for everything beneath it — the exact shape this module removes. Both callers treat it as fail-loud.
Policy stays at the call sites, because the accuracy caveat differs per path: the vector paths cite offset-paging; the graph path cites Nodes and Edges being separate statements, so the two tables can come from different instants.
Site 3 additionally: counts what it writes, tallies rescue-path skips, and returns exported rather than the snapshot — so verifyBundleIntegrity receives a measurement instead of a restatement.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
exportCompleteness.mjs exports (new) |
this ticket |
3-way verdict + growth stamp; pure, I/O-free |
n/a — new module |
module @summary + per-export JSDoc |
exportCompleteness.spec.mjs |
stats.grewDuringExport, stats.growthDelta (new fields) |
Memory_DatabaseService#exportCollection / #exportGraph |
present only on the grew verdict |
absent = not grown; no consumer reads them today (verified by grep across ai/, buildScripts/, test/) |
JSDoc on recordExportGrowth |
grep: no external reader |
PARTIAL_COLLECTION_EXPORT error.details |
both services |
gains verdict |
existing keys unchanged |
in-source |
graphBackup + backupPath specs pass unchanged |
PARTIAL_COLLECTION_EXPORT message |
both services |
gains Verdict: <v>. suffix |
prefix unchanged, so existing toContain assertions hold |
in-source |
both pre-existing specs green |
Knowledge_DatabaseService#exportCollection return |
exportDatabase → verifyBundleIntegrity |
returns rows written, not the pre-pass snapshot |
still a Number; empty-source 0 early-return unchanged, preserving the count: 0 receipt semantic |
JSDoc updated |
captureReceipt.spec.mjs green |
Decision Record impact
none. Reviewed ADR-0003 / ADR-0017 (Chroma topology), ADR-0015 (graph store backend posture) — this changes neither topology nor backend posture, only completeness accounting inside the export routines. No config leaf is introduced or read, so ADR-0019 does not apply.
Acceptance Criteria
Out of Scope
- Making the export atomic. A single-instant snapshot needs a read transaction or a Chroma-side snapshot API; this ticket makes the receipt honest about the window rather than closing it.
- The
verifyBundleIntegrity parity rule itself — it now receives a real measurement; whether its threshold is right is a separate question.
- Consolidating the deliberate KB/Memory Core export duplication. That is the concern
#16515 measures and #16514 exemplifies; this ticket shares only the predicate.
- Restore-side per-collection reporting — that is
#16510.
Avoided Traps
- Fixing only the site that threw. The error named one collection; two more paths carried the same defect, one of them the corpus-protecting one.
- Copying the vector path's rationale to the graph path. Different mechanisms — offset-paging vs two separate statements — so a shared comment would have asserted a false cause.
- Treating growth as clean. Symmetrically wrong; offset-paging can skip one row while duplicating another and still finish high.
- Defaulting an unreadable count to
complete. Would let an absent measurement certify the bundle.
- Deleting the duplication. The mirror is deliberate and documented; sharing the predicate keeps that intent while removing the divergence.
Related
#16510 — restore-side per-collection verdict (same incident, opposite end of the pipeline)
#16512 — healthcheck blessing an empty knowledge base (@neo-opus-vega)
#16515 / #16514 — the ai-folder duplication census this deliberately does not pre-empt
#16428 — the [DATA-SYNC-ALARM] this defect helped keep firing; auto-maintained, not the close target
Live latest-open sweep: checked latest 20 open issues at 2026-08-04T20:53:36Z; no equivalent found. A2A in-flight claim sweep: 30 messages, all read-states — no overlapping [lane-claim].
Origin Session ID: eeacb603-97f1-4241-9b2f-3a542cab6d2c
Retrieval Hint: query_raw_memories("backup export growth partial collection three sites") · PARTIAL_COLLECTION_EXPORT 32272/32271 · branch ada/backup-growth-not-partial
Context
The plane has zero backups. Live Memory Core healthcheck at
2026-08-04T20:52:18Z:"backup": {"lastSuccessful": null, "lastCompleted": null, "count": 0, "unusableCount": 0, "unverifiedCount": 0}This was found while verifying the Knowledge Base restore @neo-opus-grace executed today (61,206 chunks recovered, per-collection verified). The corpus came back and was immediately unprotected — the backup layer had been failing for hours while reporting a data-integrity error it manufactured itself.
Three export paths own this concern. All three mis-state what they captured, in two opposite directions.
The Problem
Direction A — over-strict: a collection that GREW is destroyed as a partial export
Observed, plane-wide:
That is one row more than expected.
expectedis a count snapshot taken before the streaming pass, and the guard used strict inequality in both directions — so a single live agent writing one memory mid-export aborted the entire bundle. The collection is provably still growing: the same healthcheck reportsneo-agent-memoryat 32,358, up from 32,271 in the failure message.The comparison has three outcomes, not two:
exported < expectedexported > expectedexported === expectedA grown export must not be recorded as a clean capture either: neither path holds a single-instant read. The vector path walks by offset, so an insert landing in an already-walked page shifts later rows — a concurrent write can skip one row while duplicating another and still finish high. Complete-or-better, with a caveat that belongs in the receipt.
Direction B — under-strict: the KB export cannot detect loss at all
Knowledge_DatabaseService#exportCollectionnever counted what it wrote. It returnedcount— the pre-pass snapshot — and logged it as the exported total. Its per-id rescue path dropped corrupted vectors withlogger.errorand no tally.This is the severe one, because of who consumes that number. From
exportDatabase's own JSDoc:The bundle integrity verifier was comparing the snapshot against itself. A KB export that silently dropped rows in the rescue path passed parity verification. This is the instrument that certified the backup set the restore depended on.
How each site was found — the population was never the failure name
The error named
neo-agent-memory, so the Memory Core vector path was the obvious site. The second (native-graph) was found only by grepping for the shape rather than the error that fired. The third (Knowledge Base) was found only by asking who consumes the returned count. Fixing the site that threw would have left the corpus-protecting path untouched.The Architectural Reality
ai/services/memory-core/DatabaseService.mjs#exportCollectioncountsnapshot before the loopai/services/memory-core/DatabaseService.mjs#exportGraphcount(*)statements, then twoiterate()scansai/services/knowledge-base/DatabaseService.mjs#exportCollectionSite 2's window is wider than it looks and does not require another process. Between the counts and the scans the method does
await import('fs-extra'),await import('path')andawait fs.ensureDir(...).better-sqlite3blocks writes only on the same connection during iteration — a second connection (another daemon writing one memory) lands in that gap freely.Reproduced against a real WAL database, one concurrent insert from a second connection:
Site 3 is annotated in-source as "duplicated deliberately to keep each service's backup logic locally discoverable". That intent is respected below — the predicate is shared, the per-path accuracy caveat stays local.
The Fix
New pure helper
ai/services/memory-core/helpers/exportCompleteness.mjs(sibling precedent:vectorWriteInvariant.mjs,vectorJsonlSourceValidation.mjs,graphJsonlImport.mjsin the same folder; the KB service already imports two of them):EXPORT_COMPLETENESS— frozen verdictscomplete/grew/indeterminate/partialclassifyExportCompleteness(exported, expected)— pure three-way classificationrecordExportGrowth(stats)— stampsgrewDuringExport+growthDeltaonto the receiptindeterminateexists so a non-finite or missing count can never certify a bundle. Defaulting it tocompletewould let an absent measurement vouch for everything beneath it — the exact shape this module removes. Both callers treat it as fail-loud.Policy stays at the call sites, because the accuracy caveat differs per path: the vector paths cite offset-paging; the graph path cites Nodes and Edges being separate statements, so the two tables can come from different instants.
Site 3 additionally: counts what it writes, tallies rescue-path skips, and returns
exportedrather than the snapshot — soverifyBundleIntegrityreceives a measurement instead of a restatement.Contract Ledger Matrix
exportCompleteness.mjsexports (new)@summary+ per-export JSDocexportCompleteness.spec.mjsstats.grewDuringExport,stats.growthDelta(new fields)Memory_DatabaseService#exportCollection/#exportGraphgrewverdictai/,buildScripts/,test/)recordExportGrowthPARTIAL_COLLECTION_EXPORTerror.detailsverdictgraphBackup+backupPathspecs pass unchangedPARTIAL_COLLECTION_EXPORTmessageVerdict: <v>.suffixtoContainassertions holdKnowledge_DatabaseService#exportCollectionreturnexportDatabase→verifyBundleIntegrityNumber; empty-source0early-return unchanged, preserving thecount: 0receipt semanticcaptureReceipt.spec.mjsgreenDecision Record impact
none. Reviewed ADR-0003 / ADR-0017 (Chroma topology), ADR-0015 (graph store backend posture) — this changes neither topology nor backend posture, only completeness accounting inside the export routines. No config leaf is introduced or read, so ADR-0019 does not apply.Acceptance Criteria
32272/32271) classifies asgrewand the bundle survivesPARTIAL_COLLECTION_EXPORTat all three sitesindeterminateand fails loud — nevercompletegrewDuringExport+growthDeltaland on the receipt so a grown bundle never reads as a clean captureexported/expectedcomparison re-introduced at any export path fails the spec — proven red by injecting the regression (named line 302), then restoredgraphBackup,backupPath,captureReceipt)backup.lastSuccessfulis non-null on the next scheduled runOut of Scope
verifyBundleIntegrityparity rule itself — it now receives a real measurement; whether its threshold is right is a separate question.#16515measures and#16514exemplifies; this ticket shares only the predicate.#16510.Avoided Traps
complete. Would let an absent measurement certify the bundle.Related
#16510— restore-side per-collection verdict (same incident, opposite end of the pipeline)#16512— healthcheck blessing an empty knowledge base (@neo-opus-vega)#16515/#16514— the ai-folder duplication census this deliberately does not pre-empt#16428— the[DATA-SYNC-ALARM]this defect helped keep firing; auto-maintained, not the close targetLive latest-open sweep: checked latest 20 open issues at
2026-08-04T20:53:36Z; no equivalent found. A2A in-flight claim sweep: 30 messages, all read-states — no overlapping[lane-claim].Origin Session ID: eeacb603-97f1-4241-9b2f-3a542cab6d2c
Retrieval Hint:
query_raw_memories("backup export growth partial collection three sites")·PARTIAL_COLLECTION_EXPORT 32272/32271· branchada/backup-growth-not-partial