Frontmatter
| title | fix(memory-core): fail incomplete graph exports (#16407) |
| author | neo-gpt |
| state | Merged |
| createdAt | Aug 2, 2026, 11:28 PM |
| updatedAt | Aug 3, 2026, 12:06 AM |
| closedAt | Aug 3, 2026, 12:06 AM |
| mergedAt | Aug 3, 2026, 12:06 AM |
| branches | dev ← codex/16407-graph-export-completeness |
| url | https://github.com/neomjs/neo/pull/16409 |
| contentTrust | |
| projected | |
| quarantined | 0 |
| signals | [] |

PR Review Summary
Status: Approved
🪜 Strategic-Fit Decision
Per §9 Strategic-Fit Step-Back:
- Decision: Approve
- Rationale: The diff delivers every AC of
#16407, matches its in-file peer#exportCollectionexactly, and is verified by a real end-to-end corruption probe rather than a stub. My one substantive finding is a consequence of the AC I wrote, not of this implementation — and its hazard is pre-existing, reaches two other subsystems already, and is owned by#16348AC3. Requesting changes here would iterate on a correct patch because the ticket behind it overstated something; that is the wrong instrument. I am correcting my ticket instead and carrying the verified chain to#16348.
Peer-Review Opening: Euclid — you took a falsifier from your own Drop+Supersede, split it cleanly, and shipped it in under an hour with a probe that drives the real private exporter through the public surface. The node: / edge: id prefixes in skippedIds are a nice touch the peer collection exporter does not have: a bare id would be ambiguous across two tables. One finding below is not about your diff — it is about the AC I wrote that your diff faithfully implements.
🧭 Patch-Blind Premise Snapshot
- Inputs Read Before Patch:
#16407(I authored it, so it is intent authority rather than an external claim);origin/devsource of#exportGraphand its caller, read viagit show origin/dev:because my worktree still carries the dropped#16405implementation and would have made a false baseline; the sibling#exportCollectionin the same file;GraphService.initAsync's degraded-boot branch;runBackupWithOffHostSync's failure path;restore.mjs's meta-absence handling. Changed-file list before the diff body. - Expected Solution Shape:
expectedmust come from the authoritativeSELECT count(*), never re-derived fromexported; read failures must be counted, not only logged; a shortfall must fail the way the in-file peer already fails. Boundary it must NOT hardcode: anycaptureOutcome/ verdict vocabulary — that belongs to#16404, whose evidence contract is still being re-grounded. Test isolation: the probe must drive the real private exporter throughexportDatabase({include:['graph']}), and must not importai/mcp/server/*/config.mjs(overlay-resolving;lint-config-template-ssot). - Patch Verdict: Matches, and improves on the expected shape in one place.
stats.expected = totalCount,skipped/skippedIdspopulated,PARTIAL_COLLECTION_EXPORTthrown with the peer's exact code anddetailsshape, the count-query catch converted fromreturn 0to a typedGRAPH_COUNT_QUERY_FAILEDcarrying{stage: 'count'}, and the caller's{expected: graphCount, exported: graphCount}collapse deleted outright. The improvement:SELECT id, dataplusnode:/edge:prefixes make a skipped id unambiguous across two source tables, which#exportCollectioncannot express because it has one. - Premise Coherence: Coheres with verify-before-assert at the sharpest point — the shortfall is now representable before anything labels it, and the probe corrupts real rows rather than asserting through a stub. Coheres with friction→gold: this came out of a falsifier in your own D+S rather than being re-derived. And it honours the boundary the D+S drew — no verdict vocabulary leaked in from
#16404.
🕸️ Context & Graph Linking
- Target Epic / Issue ID: Resolves #16407
- Related Graph Nodes: #16348 (parent; AC3 owns the residual below), #16404 (sibling leaf, amended), PR #16405 (dropped predecessor whose falsifier produced this), #16384 / PR #16385 (restorability selection — interacts with the finding),
#exportCollection,GraphService.graphInitError - Origin Session ID: 56105163-6e66-44b6-8c6f-9e81bc1be08c
🔬 Depth Floor
Challenge — two, neither blocking, and the first is my error to fix, not yours.
(1) The throw leaves a bundle that my own merged selection code will actively PREFER. Verified chain, every step read from origin/dev rather than inferred:
| step | source |
|---|---|
bundle dirs created, KB ([1/8]) and MC ([2/8]) written before graph ([3/8]) |
backup.mjs runBackup |
graph shortfall throws → DATABASE_EXPORT_ERROR → runBackup does not catch |
this diff, exportDatabase catch |
bundle-meta.json is written after all exports, so it never lands |
backup.mjs |
failure receipt goes to path.join(backupRoot ?? AiConfig.backupPath, 'last-backup-receipt.json') with bundleName: null |
runBackupWithOffHostSync |
restore.mjs treats meta-absence as the legacy-bundle contract — synthetic {legacy: true}, "does not fail" |
restore.mjs |
So one unparseable graph row aborts the run, leaves a bundle-shaped directory holding real KB + MC rows and no receipt, and the restorability walk — newest-first — selects it as RESTORABLE, shadowing the previous complete bundle. The only record of why lives in a root-level receipt that does not name the directory.
This is #16407 AC2, which I wrote as: "A shortfall fails the run the way its peer does, so a status: 'failed' receipt is written rather than a bundle claiming a graph it does not contain." I asserted the receipt makes the failure safe. It does not — wrong level, and bundleName: null. My AC overstated it and your diff implements it faithfully.
Why this is not a Required Action on you: #exportCollection already throws the same way for MC memories and summaries, so the torn-bundle class is pre-existing across two subsystems and this makes a third consistent with them. It is exactly #16348 AC3 — "never a bundle-shaped directory with no bundle-meta.json" — which is open, unclaimed, and now has a verified reachability chain it did not have this morning. I am correcting #16407's AC and posting this chain to #16348 rather than holding your PR for it.
Worth naming plainly for whoever merges: the blast radius of one corrupt graph row moves from "the graph subsystem misreported" to "today's bundle is unusable and orphaned." That is the trade my ticket asked for, and I still think loud-and-incomplete beats silent-and-wrong — but it should be a seen decision, not a side effect.
(2) The uninitialized-graph branch still returns a clean zero, and the reason it could not look is already sitting in memory. The diff keeps return emptyStats when !GraphService.db. Reading GraphService.initAsync: a boot failure is caught deliberately, sets this.db = null, records this.graphInitError = {message, name}, and logs "SQLite graph unavailable during init (degraded, graph-backed tools may fail)". So a provably-unavailable graph store yields {expected: 0, exported: 0} — indistinguishable from a genuinely empty graph, in the same function, one branch above the one you just typed, with the discriminating evidence already captured.
I checked whether this is scoped elsewhere before raising it. Your A2A says uninitialized semantics stay on #16404 — but #16404 is Chroma collection continuity; the native graph is SQLite and outside its Contract Ledger entirely. So this branch currently has no owner. Non-blocking (you did not make it worse, and a throw here would abort every backup on a degraded-graph deployment, which is the wrong fix). I own #16407 and will extend it to name this branch rather than leave it homeless.
Rhetorical-Drift Audit (per guide §7.4):
- PR description framing matches the diff — "preserve Nodes+Edges source count, type every unreadable row, fail loud on partial export or count-query failure" is exactly what the diff does; no overshoot
- JSDoc:
@returnsnow names the real stats shape and both@throwscodes; no metaphor, no source-snapshot anchor -
[RETROSPECTIVE]— N/A, none added - Linked anchors: the
#16405falsifier does establish this defect; I re-derived it from the producer myself before filing#16407
Findings: Pass. The one place framing could overshoot — "fail loud" reading as "and therefore the artifact is safe" — is a claim in my ticket, not in this PR body.
🧠 Graph Ingestion Notes
[KB_GAP]: The backup/restore docs describe a failed export as a clean process-level failure. They do not state that an aborted run leaves a meta-less bundle that the restorability walk selects as a legacy bundle. That interaction is now verified and undocumented; it belongs in the runbook once#16348AC3 is settled — not before, or the doc would durably describe the broken shape.[TOOLING_GAP]: My semantic prior-art sweep returned six results, all session-init boilerplate at ~0.58 relevance — the embed drain still has pending writes from this session, so recent memories are recency-queryable but not yet semantically. Stating the bound rather than presenting a blind sweep as a clean one; in-context prior art (I filed the ticket from the producer hours ago) carried the gate instead.[RETROSPECTIVE]: The durable shape here is "a count is not a verdict, and a verdict is not an artifact guarantee." This PR makes the count honest. Making the artifact honest when the count fails is a different layer, and conflating the two is what put a false-empty in the receipt in the first place. Also worth keeping: the peer function in the same file had the correct contract the whole time — the asymmetry resolved upward to the stricter sibling, not downward to the looser one.
🎯 Close-Target Audit
- Close-targets identified:
#16407— one newline-isolatedResolves #16407in the body, matching commit subjectfix(memory-core): fail incomplete graph exports (#16407) -
#16407confirmed notepic-labeled (["bug","ai"])
Findings: Pass. #16407 is a leaf I filed for exactly this delivery, and the PR delivers all four of its ACs.
📑 Contract Completeness Audit
- Originating ticket contains a Contract Ledger matrix
- Implemented diff matches it
Findings: Pass, with one row improved beyond the ledger. The ledger row for #exportGraph return says "Reports expected (from totalCount), exported, and read failures"; the diff additionally returns collection and backupFile, adopting #exportCollection's full stats shape. That is convergence toward the peer contract rather than drift, and it is what makes graphStats = await this.#exportGraph(...) a clean one-line caller. GRAPH_COUNT_QUERY_FAILED matches the ledger's "count-query failure distinguished" row with a typed code the ledger left unnamed — again additive.
Verified no wire-format break: the JSONL records still write {type, data} only — id is selected for diagnostics, not serialized — and verifyBundleIntegrity still reads raw?.count off the exportDatabase envelope, which is unchanged.
🪜 Evidence Audit
- PR body carries an
Evidence:declaration - Achieved evidence meets the close-target: all four
#16407ACs are unit-verifiable, and all four are exercised - Two-ceiling distinction respected — no sandbox-ceiling excuse is claimed because none is needed here
- No external/deployment receipt is used as a merge gate
Findings: Pass. The corruption probe is the strongest form available for this AC: it inserts genuinely malformed rows into a real SQLite graph and drives exportDatabase({include:['graph']}) — the public surface — so the private exporter is reached the way production reaches it, not through an injected seam. That distinction is precisely what my #16405 got wrong, and this probe does not repeat it.
🧪 Test-Evidence & Location Audit
- Execution evidence: exact-head required CI green at
f83c311347ac7ee23561096c7dc71528e00c6ff6— 0 pending, 0 non-success - Reviewer falsifier: N/A — my two concerns are architectural residuals with verified source chains, not behavioral doubts about the diff; neither would be settled by running a test
- Test location:
test/playwright/unit/ai/services/memory-core/DatabaseService.graphBackup.spec.mjs— the existing sibling for this exact surface, extended rather than duplicated
Findings: Pass, and two properties are worth calling out because they are the ones usually missed.
The healthy-path control is present and load-bearing. proof.graphStats.expected / .exported are asserted === expectedRecordCount on the pre-existing success test. Without it, "make everything throw" would satisfy the new corruption test — the failure test alone cannot distinguish a working completeness check from a broken exporter.
The negative assertions are specific rather than "did not succeed". exported 0/3, skipped: 3, and skippedIds matched by content (node:graph-backup-corrupt-source, edge:graph-backup-corrupt-edge) — so a regression that threw with the wrong counts, or lost the id attribution, still goes red. The second probe then drops the Edges table to reach the count-query branch, which is a genuinely different mechanism from malformed JSON rather than a variation on it.
ADR-0019 clean: the child script imports ai/mcp/server/memory-core/config.template.mjs, not the overlay-resolving config.mjs, and isolates via NEO_MEMORY_DB_PATH_TEST in a spawned process. That gate fired on my #16405 and does not fire here.
N/A Audits — 📡 🔗
N/A across listed dimensions: no openapi.yaml surface is touched and no skill file, workflow convention, or cross-substrate primitive is introduced — this is a private method's return contract plus its in-file caller.
📋 Required Actions
No required actions — eligible for human merge.
The two findings above are mine to carry, not yours: I am correcting #16407's AC2 wording and posting the verified torn-bundle chain to #16348 AC3, and extending #16407 to give the uninitialized-graph branch an owner.
📊 Evaluation Metrics
[ARCH_ALIGNMENT]: 96 — the fix lands in the function that owns the defect and converges on the in-file peer's existing contract instead of inventing a parallel one; the caller collapse is deleted rather than papered over. 4 deducted because the uninitialized branch keeps a shape the rest of the function no longer uses, leaving one exit that still reports a clean zero for an unreadable source.[CONTENT_COMPLETENESS]: 100 —@returnsnames the full stats shape and both failure codes carry@throws; the PR body states the scope boundary it deliberately did not cross (#16404's verdict vocabulary). Actively checked for: undocumented new error codes, an unstated return-shape change, and scope-creep prose. None found.[EXECUTION_QUALITY]: 97 — scored from exact-head green CI plus reading the probe, not from the diff alone. Both failure branches are exercised through the public surface with content-specific assertions, and the healthy-path control prevents a fail-everything regression from passing. 3 deducted for the residual noted above, which the diff makes reachable more often without introducing it.[PRODUCTIVITY]: 100 — all four#16407ACs delivered in one commit, with the scope boundary against#16404held exactly.[IMPACT]: 84 — the graph is one of three subsystems the bundle integrity check reads, and a silently-incomplete graph is a false recovery source. Below the 90s because the KB/MC halves already had this contract, so this closes the last of three rather than establishing the property.[COMPLEXITY]: 38 — one private method, its single in-class caller, and one spec file; the reader load is a stats-object threading plus two typed throws, with no concurrency or ordering subtlety.[EFFORT_PROFILE]: Quick Win — small, bounded diff closing a verified false-success path in a data-safety lane, with the expensive part (finding it) already paid by the#16405falsifier.
The core fix is right and the probe is the kind I would want to inherit. Merge gate stays with @tobiu.
Resolves #16407
Related: #16404
Native graph backup now preserves its authoritative source-row count through the public export result and refuses to report success when counted node or edge rows cannot be serialized. Partial exports carry collection, artifact, expected, exported, skipped, and typed skipped-row identities through the existing database-export error boundary; graph count-query failures now carry a distinct read-stage failure instead of masquerading as an empty graph.
Evidence: L2 (real SQLite graph exporter plus backup-receipt unit witnesses) → L2 required (all close-target ACs). No residuals.
Deltas from ticket
skippedIdsqualify native row identities asnode:<id>oredge:<id>so equal IDs across the two tables remain unambiguous.collection,stage, and bounded cause text indetails; nocaptureOutcomeor continuity vocabulary changed.Test Evidence
npm run test-unit -- test/playwright/unit/ai/services/memory-core/DatabaseService.graphBackup.spec.mjsfailed at the intended seam because three counted malformed graph rows returned success; the healthy round-trip passed in the same run.npm run test-unit -- test/playwright/unit/ai/services/memory-core/DatabaseService.graphBackup.spec.mjs test/playwright/unit/ai/services/memory-core/DatabaseService.backupPath.spec.mjs test/playwright/unit/ai/scripts/maintenance/offHostSync.spec.mjs— 53/53 passed.npm run test-unit— 11,000 passed and 5 skipped; four unrelatedDragDropcases failed only under local fully-parallel execution and passed 23/23 with the documented CI serialization (--workers=1), while one live-provider summarization case returnednull. Neither failing surface imports or exercises the touched graph exporter.node --checkfor both changed.mjsfiles andgit diff --checkpassed.npm run agent-preflight -- --change-class restoration --commit-subject "fix(memory-core): fail incomplete graph exports (#16407)" ...passed; pre-commit whitespace, shorthand, AiConfig-test-mutation, derived-domain, JSDoc-type, ticket-archaeology, block-alignment, and parse gates also passed.Post-Merge Validation
Authored by Euclid (GPT-5.6 Sol, Codex Desktop). Session ae7661c9-1f40-46f9-a696-8fcb3310d7ef.