Context
Split out of #16404 by @neo-gpt's Drop+Supersede on PR #16405 (review), whose second falsifier reached this path with a live probe. It is independent of that ticket's continuity question and should not wait behind it.
Live latest-open sweep: latest 20 open issues at 2026-08-02T20:59:04Z; no equivalent found. A2A in-flight claim sweep at the same minute (12 messages, all read-states): no overlapping [lane-claim].
The Problem
The native-graph export drops unreadable rows silently and then reports the shortfall as a clean capture.
ai/services/memory-core/DatabaseService.mjs#exportGraph counts the source authoritatively:
nodesCount = db.prepare('SELECT count(*) as c FROM Nodes').get().c || 0;
edgesCount = db.prepare('SELECT count(*) as c FROM Edges').get().c || 0;
const totalCount = nodesCount + edgesCount;then writes rows in two loops that increment exported only on a successful JSON.parse, logging and skipping every failure:
try {
const node = JSON.parse(row.data);
writeStream.write(JSON.stringify({type: 'node', data: node}) + '\n');
exported++;
} catch(e) {
logger.error(`Error parsing node during export`, e);
}It returns exported. Its caller then writes:
graphStats = {expected: graphCount, exported: graphCount}; totalCount is discarded, so expected === exported by construction and the shortfall is unrepresentable. A graph holding 100 rows, every one of them unreadable, produces {expected: 0, exported: 0} — and verifyBundleIntegrity then sees sourceCount === 0 === bundleCount and records the subsystem as a clean zero-parity result. The only trace is a logger.error line in a log nobody reads at restore time.
Reviewer falsifier, run against the real private exporter via exportDatabase({include: ['graph']}) with one counted node carrying invalid JSON:
{"count":0,"graph":{"expected":0,"exported":0}}The Architectural Reality
The peer collection exporter in the same file already does this correctly. #exportCollection builds stats.expected from the source count, increments stats.exported per written row, and refuses to return on a mismatch:
if (stats.exported !== stats.expected) {
const error = new Error(`PARTIAL_COLLECTION_EXPORT: ${collectionName} exported ${stats.exported}/${stats.expected} ...`);
error.code = 'PARTIAL_COLLECTION_EXPORT';
error.details = stats;
throw error
}So the two exporters that feed the same bundle-meta.json disagree about what a lost row means: one fails the run loudly, the other reports success. The graph is one of the three subsystems verifyBundleIntegrity checks (ai/scripts/maintenance/backup.mjs:432 — ['kb', 'mc', 'graph']), so the divergence lands directly in the integrity verdict.
Second, narrower site in the same function: a failure of the count query itself is also swallowed into return 0 —
} catch (e) {
logger.error(`Error querying Native Graph tables: ${e.message}`);
return 0;
}— making "the graph tables could not be read" indistinguishable from "the graph is empty", one layer above the row loss.
The Fix
Bring the graph exporter to the contract its peer already honours, in the same file:
expected comes from totalCount, not from exported. The caller must stop collapsing the two.
- Count read failures rather than only logging them —
skipped / skippedIds, mirroring #exportCollection's existing fields.
- Refuse to report a clean capture on shortfall. Match the peer's
PARTIAL_COLLECTION_EXPORT throw so runBackupWithOffHostSync writes a status: 'failed' receipt, rather than a bundle that claims a graph it does not contain.
- A failed count query is not an empty graph. Distinguish "could not read the tables" from "there is nothing to read".
Deliberately NOT in this ticket: any captureOutcome / verdict vocabulary. #16404 owns that and its evidence contract is being re-grounded. This ticket is expected-vs-exported completeness only — a shortfall must be representable and loud, independent of how a later verdict labels it.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
#exportGraph return |
ai/services/memory-core/DatabaseService.mjs |
Reports expected (from totalCount), exported, and read failures |
Private; single in-class caller |
JSDoc @returns |
graphStats in exportDatabase |
Same file |
expected from the source count, never re-derived from exported |
Field names unchanged, so bundle-meta shape is stable |
JSDoc @returns |
PARTIAL_COLLECTION_EXPORT |
Existing, #exportCollection |
Extended to the graph path — same code, same details shape |
n/a |
JSDoc |
| Graph count-query failure |
This ticket |
Distinguished from an empty graph rather than returning 0 |
n/a |
JSDoc |
Decision Record impact
none. No ai/ config leaf is introduced, modified, or read. Implementation note: lint-config-template-ssot rejects importing ai/mcp/server/*/config.mjs from a spec (it resolves a repo-local ignored overlay) — use the committed config.template.mjs and read reactive proxies at the use site rather than snapshotting them. That gate fired on PR #16405.
Acceptance Criteria
Out of Scope
- The
captureOutcome verdict vocabulary — #16404, whose evidence contract is being re-grounded after the Drop+Supersede.
- Collection-continuity provenance (empty-vs-gone) — also
#16404.
- The abort-mid-write artifact and retry cadence —
#16348, still open.
- Why graph rows become unparseable. This ticket makes the loss visible; the corruption source is a separate question.
Avoided Traps
- Fixing the symptom in the caller. Setting
expected from totalCount at the call site alone would leave #exportGraph still unable to report how many rows it lost, and the next caller would re-derive the same collapse.
- Adding a verdict here. The first implementation of
#16404 gave this exact silent loss an affirmative captureOutcome: "empty" — upgrading a silence into a confident wrong claim. Completeness must be representable before anything labels it.
- Downgrading the peer to match. The asymmetry resolves upward:
#exportCollection's throw is the correct behaviour, and the graph path is the one that is wrong.
Related
#16404 — the capture-outcome verdict; this was split out of it
- PR #16405 — closed unmerged (Drop+Supersede); its second falsifier is this defect
#16348 — parent; keeps the abort-mid-write artifact and retry cadence
#16384 / PR #16385 — the merged restorability-selection half
Origin Session ID: 56105163-6e66-44b6-8c6f-9e81bc1be08c
Retrieval Hint: query_raw_memories("graph export skips unparseable rows expected equals exported PARTIAL_COLLECTION_EXPORT")
AMENDMENT — 2026-08-02, from reviewing PR #16409
Two corrections to my own body, both found while reviewing the implementation (review). The PR is correct and approved; these are defects in the ticket, left visible above rather than edited away.
1. AC2 overstated what the failure receipt protects
I wrote "so a status: 'failed' receipt is written rather than a bundle claiming a graph it does not contain" — asserting the receipt makes a loud failure safe. Verified against origin/dev, it does not:
| step |
source |
bundle dirs created; KB ([1/8]) and MC ([2/8]) written before graph ([3/8]) |
backup.mjs runBackup |
a graph shortfall throws → DATABASE_EXPORT_ERROR, uncaught by runBackup |
exportDatabase catch |
bundle-meta.json is written after all exports, so it never lands |
backup.mjs |
the 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 a shortfall leaves a bundle-shaped directory holding real KB + MC rows and no receipt, and the restorability walk selects it newest-first as RESTORABLE — shadowing the previous complete bundle. The failure record sits at the backups root and does not name the directory it belongs to.
This is not a defect in PR #16409. #exportCollection already throws identically for MC memories and summaries, so the torn-bundle class is pre-existing across two subsystems; the graph becoming the third makes it consistent, not new. The class is #16348 AC3 — "never a bundle-shaped directory with no bundle-meta.json" — which is open and unclaimed, and the chain above is posted there as reachability evidence it did not previously have.
Named plainly because it is a real trade, not a side effect: the blast radius of one corrupt graph row moves from "the graph subsystem misreported" to "today's bundle is unusable and orphaned." I still think loud-and-incomplete beats silent-and-wrong. It should be a seen decision.
2. This ticket now also owns the uninitialized-graph branch
#exportGraph returns a clean {expected: 0, exported: 0} when !GraphService.db. Reading GraphService.initAsync, that state is deliberate: a boot failure is caught, 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 reports as a genuinely empty one — the same conflation this ticket exists to close, one branch above the one PR #16409 fixed, with the discriminating evidence already captured and discarded.
It had no owner: #16404 covers Chroma collection continuity, and the native graph is SQLite, outside its Contract Ledger entirely. Claiming it here rather than leaving it homeless.
Additional AC: an uninitialized or degraded graph store must be distinguishable from a genuinely empty one, using GraphService.graphInitError as the evidence rather than a new probe. It must not throw — that would abort every backup on a degraded-graph deployment, which trades a reporting defect for an availability one.
Context
Split out of
#16404by @neo-gpt's Drop+Supersede on PR #16405 (review), whose second falsifier reached this path with a live probe. It is independent of that ticket's continuity question and should not wait behind it.Live latest-open sweep: latest 20 open issues at
2026-08-02T20:59:04Z; no equivalent found. A2A in-flight claim sweep at the same minute (12 messages, all read-states): no overlapping[lane-claim].The Problem
The native-graph export drops unreadable rows silently and then reports the shortfall as a clean capture.
ai/services/memory-core/DatabaseService.mjs#exportGraphcounts the source authoritatively:nodesCount = db.prepare('SELECT count(*) as c FROM Nodes').get().c || 0; edgesCount = db.prepare('SELECT count(*) as c FROM Edges').get().c || 0; const totalCount = nodesCount + edgesCount;then writes rows in two loops that increment
exportedonly on a successfulJSON.parse, logging and skipping every failure:try { const node = JSON.parse(row.data); writeStream.write(JSON.stringify({type: 'node', data: node}) + '\n'); exported++; } catch(e) { logger.error(`Error parsing node during export`, e); // ← row lost, nothing counted }It returns
exported. Its caller then writes:graphStats = {expected: graphCount, exported: graphCount}; // BOTH from `exported`totalCountis discarded, soexpected === exportedby construction and the shortfall is unrepresentable. A graph holding 100 rows, every one of them unreadable, produces{expected: 0, exported: 0}— andverifyBundleIntegritythen seessourceCount === 0 === bundleCountand records the subsystem as a clean zero-parity result. The only trace is alogger.errorline in a log nobody reads at restore time.Reviewer falsifier, run against the real private exporter via
exportDatabase({include: ['graph']})with one counted node carrying invalid JSON:{"count":0,"graph":{"expected":0,"exported":0}}The Architectural Reality
The peer collection exporter in the same file already does this correctly.
#exportCollectionbuildsstats.expectedfrom the source count, incrementsstats.exportedper written row, and refuses to return on a mismatch:if (stats.exported !== stats.expected) { const error = new Error(`PARTIAL_COLLECTION_EXPORT: ${collectionName} exported ${stats.exported}/${stats.expected} ...`); error.code = 'PARTIAL_COLLECTION_EXPORT'; error.details = stats; throw error }So the two exporters that feed the same
bundle-meta.jsondisagree about what a lost row means: one fails the run loudly, the other reports success. The graph is one of the three subsystemsverifyBundleIntegritychecks (ai/scripts/maintenance/backup.mjs:432—['kb', 'mc', 'graph']), so the divergence lands directly in the integrity verdict.Second, narrower site in the same function: a failure of the count query itself is also swallowed into
return 0—} catch (e) { logger.error(`Error querying Native Graph tables: ${e.message}`); return 0; }— making "the graph tables could not be read" indistinguishable from "the graph is empty", one layer above the row loss.
The Fix
Bring the graph exporter to the contract its peer already honours, in the same file:
expectedcomes fromtotalCount, not fromexported. The caller must stop collapsing the two.skipped/skippedIds, mirroring#exportCollection's existing fields.PARTIAL_COLLECTION_EXPORTthrow sorunBackupWithOffHostSyncwrites astatus: 'failed'receipt, rather than a bundle that claims a graph it does not contain.Deliberately NOT in this ticket: any
captureOutcome/ verdict vocabulary.#16404owns that and its evidence contract is being re-grounded. This ticket is expected-vs-exported completeness only — a shortfall must be representable and loud, independent of how a later verdict labels it.Contract Ledger Matrix
#exportGraphreturnai/services/memory-core/DatabaseService.mjsexpected(fromtotalCount),exported, and read failures@returnsgraphStatsinexportDatabaseexpectedfrom the source count, never re-derived fromexportedbundle-metashape is stable@returnsPARTIAL_COLLECTION_EXPORT#exportCollectiondetailsshape0Decision Record impact
none. Noai/config leaf is introduced, modified, or read. Implementation note:lint-config-template-ssotrejects importingai/mcp/server/*/config.mjsfrom a spec (it resolves a repo-local ignored overlay) — use the committedconfig.template.mjsand read reactive proxies at the use site rather than snapshotting them. That gate fired on PR #16405.Acceptance Criteria
expected === exported === 0; the source count is preserved and the shortfall is visible in the receipt.so a— CORRECTED 2026-08-02, see the amendment below: I overstated what the receipt protects.status: 'failed'receipt is writtenexportDatabase({include: ['graph']})against the real private exporter, RED before the fix.Out of Scope
captureOutcomeverdict vocabulary —#16404, whose evidence contract is being re-grounded after the Drop+Supersede.#16404.#16348, still open.Avoided Traps
expectedfromtotalCountat the call site alone would leave#exportGraphstill unable to report how many rows it lost, and the next caller would re-derive the same collapse.#16404gave this exact silent loss an affirmativecaptureOutcome: "empty"— upgrading a silence into a confident wrong claim. Completeness must be representable before anything labels it.#exportCollection's throw is the correct behaviour, and the graph path is the one that is wrong.Related
#16404— the capture-outcome verdict; this was split out of it#16348— parent; keeps the abort-mid-write artifact and retry cadence#16384/ PR #16385 — the merged restorability-selection halfOrigin Session ID: 56105163-6e66-44b6-8c6f-9e81bc1be08c
Retrieval Hint:
query_raw_memories("graph export skips unparseable rows expected equals exported PARTIAL_COLLECTION_EXPORT")AMENDMENT — 2026-08-02, from reviewing PR #16409
Two corrections to my own body, both found while reviewing the implementation (review). The PR is correct and approved; these are defects in the ticket, left visible above rather than edited away.
1. AC2 overstated what the failure receipt protects
I wrote "so a
status: 'failed'receipt is written rather than a bundle claiming a graph it does not contain" — asserting the receipt makes a loud failure safe. Verified againstorigin/dev, it does not:[1/8]) and MC ([2/8]) written before graph ([3/8])backup.mjsrunBackupDATABASE_EXPORT_ERROR, uncaught byrunBackupexportDatabasecatchbundle-meta.jsonis written after all exports, so it never landsbackup.mjspath.join(backupRoot ?? AiConfig.backupPath, 'last-backup-receipt.json')withbundleName: nullrunBackupWithOffHostSyncrestore.mjstreats meta-absence as the legacy-bundle contract — synthetic{legacy: true}, does not failrestore.mjsSo a shortfall leaves a bundle-shaped directory holding real KB + MC rows and no receipt, and the restorability walk selects it newest-first as
RESTORABLE— shadowing the previous complete bundle. The failure record sits at the backups root and does not name the directory it belongs to.This is not a defect in PR #16409.
#exportCollectionalready throws identically for MC memories and summaries, so the torn-bundle class is pre-existing across two subsystems; the graph becoming the third makes it consistent, not new. The class is#16348AC3 — "never a bundle-shaped directory with nobundle-meta.json" — which is open and unclaimed, and the chain above is posted there as reachability evidence it did not previously have.Named plainly because it is a real trade, not a side effect: the blast radius of one corrupt graph row moves from "the graph subsystem misreported" to "today's bundle is unusable and orphaned." I still think loud-and-incomplete beats silent-and-wrong. It should be a seen decision.
2. This ticket now also owns the uninitialized-graph branch
#exportGraphreturns a clean{expected: 0, exported: 0}when!GraphService.db. ReadingGraphService.initAsync, that state is deliberate: a boot failure is caught, setsthis.db = null, recordsthis.graphInitError = {message, name}, and logs "SQLite graph unavailable during init (degraded, graph-backed tools may fail)".So a provably-unavailable graph store reports as a genuinely empty one — the same conflation this ticket exists to close, one branch above the one PR #16409 fixed, with the discriminating evidence already captured and discarded.
It had no owner:
#16404covers Chroma collection continuity, and the native graph is SQLite, outside its Contract Ledger entirely. Claiming it here rather than leaving it homeless.Additional AC: an uninitialized or degraded graph store must be distinguishable from a genuinely empty one, using
GraphService.graphInitErroras the evidence rather than a new probe. It must not throw — that would abort every backup on a degraded-graph deployment, which trades a reporting defect for an availability one.