Context
Surfaced by @tobiu as a V-B-A on PR #16562: "last (broken) backup flagged as a success." Verified on the live plane.
The 2026-08-05T09:39 daily backup ran while neo-knowledge-base held 0 documents (the three-day corpus outage). Its bundle-meta.json:
"kb": {
"message": "Export complete. Exported 0 knowledge base chunks.",
"count": 0,
"collectionId": "ab75f86b-1651-4865-96f4-0287acd42ea7"
}And on disk:
backup-2026-08-05T09-39-56.157Z/kb/ ← empty directory, no .jsonl at all
backup-2026-08-05T09-39-56.157Z/ completedAt: 2026-08-05T09:40:30.836Z
"Export complete." · count: 0 · a completedAt stamp · no degradation of any kind. Total bundle 2.0 GB, so it is a substantial, plausible-looking artifact.
Observation vs inference: the meta contents, the empty directory, the counts and the collectionId are measured. What a consumer then does with this bundle is reasoned from shipped control flow and marked as such below.
Scale update 2026-08-07 — it is 6 of 10 bundles, not one
Full inventory of the real backup root (/Users/tobiasuhlig/.neo-ai/backups), wc -l on each bundle's kb/*.jsonl:
| bundle |
kb bytes |
kb rows |
bundle-meta.json |
| 2026-07-31T04-57 |
0 |
0 |
yes |
| 2026-08-01T05-01 |
0 |
0 |
yes |
| 2026-08-01T12-13 |
3,368,227,090 |
61,206 |
yes |
| 2026-08-03T08-09 |
107,196,660 |
2,001 |
NO |
| 2026-08-03T21-37 |
3,287,574,636 |
59,754 |
yes |
| 2026-08-04T08-38 |
0 |
0 |
yes |
| 2026-08-04T08-55 |
0 |
0 |
yes |
| 2026-08-05T09-39 |
0 |
0 |
yes |
| 2026-08-05T19-05 |
0 |
0 |
yes |
| 2026-08-06T13-09 (NEWEST) |
900,214,445 |
16,550 |
yes |
Six of ten bundles carry zero KB rows, and every one of the six carries a valid bundle-meta.json. Only two hold a real corpus. This ticket's original evidence was a single bundle; the defect has produced a majority of the retained set, so it is the steady state rather than an edge case.
Two things the wider sample adds:
- The newest bundle is degraded, not empty — 16,550 rows where ~60,000 is correct. So the failure mode is not binary: a bundle can look substantial at 900 MB and still be a trap. Only the row count discriminates, and no artifact surfaces it.
08-03T08-09 has no bundle-meta.json at all yet sits in the retention set beside complete captures, at 2,001 rows. Retention discovery is bundle-shaped-directory, and a partial capture is bundle-shaped.
The policy consequence is filed separately as #16614 — retention's keepMinimum: 3 counts directories rather than restorable bundles, so the three newest bundles here contain no recoverable KB corpus, and the 59,754-row bundle is protected only by being 4 days inside a 30-day age bound. The two are complements: a correct receipt stops producing empty bundles, a correct policy stops them from displacing real ones.
The Problem
A subsystem that exported nothing reports the same shape as one that exported everything.
Compare the sibling subsystems in the same file — they are more honest than the KB:
| subsystem |
reported |
mc |
expected: 32387, exported: 32387, skipped: 0 |
graph |
expected: 271280, exported: 271280, skipped: 0 |
trajectories |
copied: 0, note: "source not present: …" |
kb |
count: 0, message "Export complete.", no note, no expectation |
trajectories explicitly records why zero is fine. The KB records zero with a completion message and nothing to distinguish "the corpus is empty" from "the corpus was captured". There is no expected field to compare against, so a zero-row KB export cannot fail its own contract — it has no contract.
The collectionId is the sharpest part: ab75f86b-… is the same id that now holds 62,486 documents. The bundle names the right collection and captured none of it.
Why this is worse than a missing backup
A missing backup is visible. This is a success-shaped artifact that becomes the newest restore source. Reasoned from shipped control flow, not observed: probeBundle / evaluateRedeployPreconditions (redeployPreflight.mjs) consume bundle state as both proof of prior state and authorization to proceed — the overload #16521 documents. An mc-populated, kb-empty bundle satisfies "non-empty" in aggregate.
The near-miss is real. @neo-opus-grace restored the Aug-3 bundle (59,754 docs), not this newer one. Had the newest bundle been chosen — the obvious choice — a --mode replace KB restore from it would have written an empty collection over a populated one. That restore was correct; nothing in the artifacts made it obviously correct.
This is the third surface of one pattern. #16512 fixed it on the health path (an empty collection degrades health). #16521 owns the verdict path. This is the producer: the exporter that mints the artifact both of those then reason about.
The Architectural Reality
ai/services/knowledge-base/DatabaseService.mjs — exportDatabase emits "Export complete. Exported N knowledge base chunks." with no non-empty expectation. Its importDatabase sibling does carry an atomic vector-write invariant that rejects rows fail-loud; the export side has no equivalent floor.
ai/scripts/maintenance/backup.mjs — assembles bundle-meta.json per subsystem and stamps completedAt. Records what each subsystem returned; does not adjudicate whether a return is degenerate.
- The
mc / graph exporters already carry expected vs exported vs skipped. The shape to copy exists in the same file.
ai/services/knowledge-base/HealthService.mjs already treats an empty KB as health-degrading (#16512). The two surfaces disagree today: health says degraded, backup says complete.
The Fix
- The KB export declares an expectation. Read the collection count first and report
expected / exported alongside count, matching mc and graph. A zero-row export against a non-zero expectation is then a failure, not a message.
- Zero rows with a zero expectation is
degraded, never complete — an empty corpus is a real state and the bundle must say so in a field a consumer can branch on, not only in prose. Follow trajectories' precedent: zero is acceptable with a recorded reason.
bundle-meta.json carries a per-subsystem status so a consumer can distinguish captured-nothing from captured-everything without string-matching a message.
Not proposed: failing the whole backup when the KB is empty. The mc + graph halves of that 2.0 GB bundle are genuinely valuable and refusing to write them would trade a silent gap for real data loss — which is the same trade #16510's withdrawn AC made and #16521 exists to undo.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback / Error Semantics |
Docs |
Evidence |
KB exportDatabase return |
this ticket, over the existing {message, count, collectionId} |
gains expected; zero-vs-expectation becomes a failure |
absent expectation ⇒ degraded, never complete |
KB service JSDoc |
live bundle-meta.json, quoted above |
bundle-meta.json subsystems.kb |
backup.mjs |
gains a status a consumer can branch on |
unknown status is never treated as success |
RestorationRunbook |
the 09:39 bundle |
mc / graph export shape |
existing |
unchanged — already carry expected/exported/skipped |
— |
— |
same bundle, same file |
probeBundle / RESTORABLE |
#16521 |
unchanged here — that ticket owns the verdict; this one stops minting the misleading input |
— |
— |
scope boundary |
Decision Record impact
none. No ADR governs export completeness semantics; this adds a floor to an existing return shape.
Acceptance Criteria
Out of Scope
RESTORABLE's dual role — #16521 owns it. This ticket stops producing the misleading input; it does not change how the verdict reads it.
- Health-surface treatment of an empty KB — already shipped (#16512).
- Why the corpus was empty — #16549, still unexplained.
- Backup scheduling / lease starvation — #16561 and PR #16562.
- Retroactively marking existing bundles. The 09:39 bundle stays as-is and becomes the fixture.
Avoided Traps
- Failing the whole bundle on an empty KB. Trades a silent gap for real data loss, and repeats #16510's withdrawn AC.
- Fixing only the message.
"Export complete" → "Export complete (empty)" still leaves consumers string-matching prose. The missing thing is a branchable field and an expectation.
- Assuming zero is always wrong. A genuinely empty corpus must remain expressible —
trajectories already models this correctly with copied: 0 plus a note.
Related
- #16521 — the verdict-side overload this ticket's output feeds.
- #16512 — the same "empty ≠ fine" fix on the health surface; the precedent for the disposition.
- #16549 — why the corpus was empty, still open.
- #16561 / PR #16562 — backup scheduling; disjoint from backup content.
- #16510 — the withdrawn AC whose failure mode the Out of Scope section preserves.
Live latest-open sweep: latest 30 open issues checked immediately before filing; nearest neighbours #16521 (verdict, not producer) and #16561 (scheduling, not content) — neither covers an exporter reporting completion on zero rows. A2A in-flight claim sweep: recent messages across all read-states, no competing claim.
Origin Session ID: 11695cce-9854-4be2-80c3-8ea4322298bf
Retrieval Hint: query_raw_memories("KB export complete zero rows bundle-meta success-shaped empty kb directory expected field")
Context
Surfaced by @tobiu as a V-B-A on PR #16562: "last (broken) backup flagged as a success." Verified on the live plane.
The 2026-08-05T09:39 daily backup ran while
neo-knowledge-baseheld 0 documents (the three-day corpus outage). Itsbundle-meta.json:"kb": { "message": "Export complete. Exported 0 knowledge base chunks.", "count": 0, "collectionId": "ab75f86b-1651-4865-96f4-0287acd42ea7" }And on disk:
"Export complete."·count: 0· acompletedAtstamp · no degradation of any kind. Total bundle 2.0 GB, so it is a substantial, plausible-looking artifact.Observation vs inference: the meta contents, the empty directory, the counts and the
collectionIdare measured. What a consumer then does with this bundle is reasoned from shipped control flow and marked as such below.Scale update 2026-08-07 — it is 6 of 10 bundles, not one
Full inventory of the real backup root (
/Users/tobiasuhlig/.neo-ai/backups),wc -lon each bundle'skb/*.jsonl:bundle-meta.jsonSix of ten bundles carry zero KB rows, and every one of the six carries a valid
bundle-meta.json. Only two hold a real corpus. This ticket's original evidence was a single bundle; the defect has produced a majority of the retained set, so it is the steady state rather than an edge case.Two things the wider sample adds:
08-03T08-09has nobundle-meta.jsonat all yet sits in the retention set beside complete captures, at 2,001 rows. Retention discovery is bundle-shaped-directory, and a partial capture is bundle-shaped.The policy consequence is filed separately as #16614 — retention's
keepMinimum: 3counts directories rather than restorable bundles, so the three newest bundles here contain no recoverable KB corpus, and the 59,754-row bundle is protected only by being 4 days inside a 30-day age bound. The two are complements: a correct receipt stops producing empty bundles, a correct policy stops them from displacing real ones.The Problem
A subsystem that exported nothing reports the same shape as one that exported everything.
Compare the sibling subsystems in the same file — they are more honest than the KB:
mcexpected: 32387, exported: 32387, skipped: 0graphexpected: 271280, exported: 271280, skipped: 0trajectoriescopied: 0,note: "source not present: …"kbcount: 0, message"Export complete.", no note, no expectationtrajectoriesexplicitly records why zero is fine. The KB records zero with a completion message and nothing to distinguish "the corpus is empty" from "the corpus was captured". There is noexpectedfield to compare against, so a zero-row KB export cannot fail its own contract — it has no contract.The
collectionIdis the sharpest part:ab75f86b-…is the same id that now holds 62,486 documents. The bundle names the right collection and captured none of it.Why this is worse than a missing backup
A missing backup is visible. This is a success-shaped artifact that becomes the newest restore source. Reasoned from shipped control flow, not observed:
probeBundle/evaluateRedeployPreconditions(redeployPreflight.mjs) consume bundle state as both proof of prior state and authorization to proceed — the overload #16521 documents. Anmc-populated,kb-empty bundle satisfies "non-empty" in aggregate.The near-miss is real. @neo-opus-grace restored the Aug-3 bundle (59,754 docs), not this newer one. Had the newest bundle been chosen — the obvious choice — a
--mode replaceKB restore from it would have written an empty collection over a populated one. That restore was correct; nothing in the artifacts made it obviously correct.This is the third surface of one pattern. #16512 fixed it on the health path (an empty collection degrades health). #16521 owns the verdict path. This is the producer: the exporter that mints the artifact both of those then reason about.
The Architectural Reality
ai/services/knowledge-base/DatabaseService.mjs—exportDatabaseemits"Export complete. Exported N knowledge base chunks."with no non-empty expectation. ItsimportDatabasesibling does carry an atomic vector-write invariant that rejects rows fail-loud; the export side has no equivalent floor.ai/scripts/maintenance/backup.mjs— assemblesbundle-meta.jsonper subsystem and stampscompletedAt. Records what each subsystem returned; does not adjudicate whether a return is degenerate.mc/graphexporters already carryexpectedvsexportedvsskipped. The shape to copy exists in the same file.ai/services/knowledge-base/HealthService.mjsalready treats an empty KB as health-degrading (#16512). The two surfaces disagree today: health says degraded, backup says complete.The Fix
expected/exportedalongsidecount, matchingmcandgraph. A zero-row export against a non-zero expectation is then a failure, not a message.degraded, nevercomplete— an empty corpus is a real state and the bundle must say so in a field a consumer can branch on, not only in prose. Followtrajectories' precedent: zero is acceptable with a recorded reason.bundle-meta.jsoncarries a per-subsystem status so a consumer can distinguish captured-nothing from captured-everything without string-matching a message.Not proposed: failing the whole backup when the KB is empty. The
mc+ graph halves of that 2.0 GB bundle are genuinely valuable and refusing to write them would trade a silent gap for real data loss — which is the same trade #16510's withdrawn AC made and #16521 exists to undo.Contract Ledger Matrix
KB exportDatabasereturn{message, count, collectionId}expected; zero-vs-expectation becomes a failurebundle-meta.json, quoted abovebundle-meta.jsonsubsystems.kbbackup.mjsmc/graphexport shapeprobeBundle/RESTORABLEDecision Record impact
none. No ADR governs export completeness semantics; this adds a floor to an existing return shape.Acceptance Criteria
bundle-meta.jsonexposes a per-subsystem status that a consumer can branch on without parsing the human message.mc+ graph halves of a KB-empty bundle still get written.Out of Scope
RESTORABLE's dual role — #16521 owns it. This ticket stops producing the misleading input; it does not change how the verdict reads it.Avoided Traps
"Export complete"→"Export complete (empty)"still leaves consumers string-matching prose. The missing thing is a branchable field and an expectation.trajectoriesalready models this correctly withcopied: 0plus anote.Related
Live latest-open sweep: latest 30 open issues checked immediately before filing; nearest neighbours #16521 (verdict, not producer) and #16561 (scheduling, not content) — neither covers an exporter reporting completion on zero rows. A2A in-flight claim sweep: recent messages across all read-states, no competing claim.
Origin Session ID: 11695cce-9854-4be2-80c3-8ea4322298bf
Retrieval Hint:
query_raw_memories("KB export complete zero rows bundle-meta success-shaped empty kb directory expected field")