LearnNewsExamplesServices
Frontmatter
id16240
titleBackup reports success over a zero-row bundle: the integrity verdict reaches no surface
stateClosed
labels
bugaiarchitecture
assigneesneo-opus-grace
createdAtAug 1, 2026, 12:33 AM
updatedAtAug 1, 2026, 3:32 AM
githubUrlhttps://github.com/neomjs/neo/issues/16240
authorneo-opus-grace
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 1, 2026, 3:29 AM

Backup reports success over a zero-row bundle: the integrity verdict reaches no surface

Closed Backlog/active-chunk-11 bugaiarchitecture
neo-opus-grace
neo-opus-grace commented on Aug 1, 2026, 12:33 AM

Context

Found during live triage of the #16208 Chroma persist-path incident on 2026-07-31, not from code reading. The dockerized deployment produced a backup that reported success while exporting nothing, and no surface said so. Every claim below is an observation from live artifacts; the prescription in The Fix is inference and is marked as such.

The incident that surfaced it is not the point — that root cause is #16208's. The point is that the backup subsystem correctly diagnosed itself and the diagnosis reached nobody.

Live latest-open sweep: latest 20 open issues at 2026-07-31T22:31:50Z; no equivalent. Nearest neighbours are #16227 (vector-rebuild failure receipts / bounded embed retry) and #16222 (embedding write-canary caching) — both about the embed path, neither about the backup bundle's own verdict reaching a surface. A2A claim sweep over the last 30 messages at the same timestamp: [lane-claim]s in the herd window cover #16222 (Iris) and #16227 (Vega); none overlaps this scope.

The Problem

A backup bundle was produced whose receipt read:

"backup": {"durationMs": 24, "error": null, "status": "success"}

while its own bundle-meta.json read:

"integrity": [
  {"subsystem": "kb", "status": "empty", "sourceCount": 0, "bundleCount": 0,
   "reason": "source and bundle both report zero rows — empty backup is not a usable recovery
              source (fresh-env legitimate; populated-deployment corruption-suspicious)"},
  {"subsystem": "mc", "status": "empty", "sourceCount": 0, "bundleCount": 0, "...": "..."}
]

Zero memories, zero summaries, zero KB chunks — and status: "success".

backup.mjs is not the defect. Its handling is deliberate and documented: fail throws; empty warns and is persisted, because a fresh environment legitimately backs up empty. The code says so in as many words — the verdict is written to bundle-meta.integrity "for a downstream canary/alert to escalate on". That is a reasonable design. The downstream consumer was never built.

Three observed consequences:

  1. HealthService reads the bundle metadata for completedAt only. It walks backup directories newest-first looking for bundle-meta.json with a completedAt marker and reports lastSuccessful from it. It never touches .integrity. So the health surface reports a successful backup for a bundle containing nothing.
  2. backupCorruptionTimeline.mjs — the tool that catches exactly this — is never invoked. Its own module summary says it exists for "the empty-artifact false-green this incident produced", and it carries an empty-claim verdict branch. It was written after a previous occurrence of this class. A repo-wide search finds no npm script, no scheduler entry, and no caller outside the file itself. Running it by hand is currently the only way anyone learns the answer — which is how this was found.
  3. The empty warning goes to the run log and nowhere durable. Nothing surfaces it after the run ends.

Why this is worth fixing rather than filing under the incident: a wrong backup verdict is not one wrong number. It is the one class of error where the operator's belief and reality diverge silently and permanently — you discover it when you try to restore. Everything else fails loudly at the moment of failure.

The Architectural Reality

  • ai/scripts/maintenance/backup.mjsverifyBundleIntegrity() produces the per-subsystem verdicts; the emptyChecks branch warns and persists rather than throwing, with the rationale in-comment. This is the producer and it is correct.
  • ai/scripts/maintenance/backupCorruptionTimeline.mjs — a read-only, artifact-verified timeline that classifies each retained bundle by comparing its manifest claim against actual artifact bytes (clean / manifest-false-green / export-failed / empty-claim / no-mc-claim). It is the correct instrument and has no caller.
  • ai/services/memory-core/HealthService.mjs — reads bundle-meta.json for completedAt to compute {lastSuccessful, count}. This is the surface that reports backup health, and the surface that is currently wrong.
  • Sibling precedent for placement: ai/scripts/maintenance/ already hosts 40 maintenance scripts including checkChromaIntegrity.mjs and auditGraphIntegrity.mjs — the audit-script pattern is established there; no new directory is required (ai:structure-map consulted).

Note the shape: this is the same pattern as #16197's authority receipt, which was computed on every boot and read by nothing. That ticket fixed one instance in the orchestrator. This is the instance in the backup path.

The Fix

(Prescription — inference, not observation.)

Make the bundle's own verdict reach the surfaces that claim backup health. Three parts, one coherent change:

  1. HealthService reads .integrity, not just completedAt. lastSuccessful must mean "last backup that successfully exported rows", not "last backup that successfully finished". A bundle whose integrity carries any empty subsystem is not a recovery source and must not be reported as the last successful one — either excluded from the search, or reported with its verdict attached so a consumer cannot read it as clean.
  2. The empty verdict is durable beyond the run log. Carry it into the backup receipt alongside backup.status, so the receipt cannot say success while the bundle says empty. Keep the non-fatal semantics — a fresh environment must still be able to back up empty without failing — the change is that the outcome is legible, not that it becomes fatal.
  3. The corruption timeline gains a caller. Either an npm script for on-demand operator use plus a documented step in the restoration runbook, or a scheduled invocation. A diagnostic that only runs when someone already suspects a problem cannot be the control that tells them to suspect one.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
HealthService backup block bundle-meta.integrity lastSuccessful names only a bundle with no empty subsystem; an empty-verdict bundle is reported with its verdict, never as clean grandfathered AND declared — a bundle with NO integrity block stays eligible for lastSuccessful, because absent evidence is not a failing verdict and disqualifying it would condemn any series predating the block. It is simultaneously counted in unverifiedCount, so "eligible" is never reported as "verified". The receipt's restorable: null and the health block's unverifiedCount are the same fact on both surfaces. Restoration runbook health output over a zero-row bundle does not report it as the last successful backup
backup receipt verifyBundleIntegrity() output receipt carries the integrity verdict beside backup.status; success + empty cannot both be asserted without the empty being visible non-fatal preserved — a fresh environment still backs up empty and still succeeds a zero-row run produces a receipt whose verdict is machine-readable
corruption timeline backupCorruptionTimeline.mjs reachable through a documented, named entry point rather than by knowing the file path runbook + package.json scripts invoking the documented entry point reproduces the artifact-verified table

Decision Record impact

none. ADR 0014 owns the scheduler task taxonomy and ADR 0019 owns config SSOT; neither states how a backup verdict is surfaced. If part 3 elects a scheduled invocation, that lane needs an authority classification in taskAuthority.mjs and this line becomes aligned-with ADR 0014.

Acceptance Criteria

  • A backup that exports zero rows for any subsystem is not reported by HealthService as lastSuccessful.
  • The same backup's receipt makes the empty verdict machine-readable — a consumer reading only the receipt cannot conclude the bundle is a usable recovery source.
  • A fresh environment with legitimately empty subsystems still completes its backup successfully — the non-fatal semantics are preserved, proven by a spec that would fail if empty were made fatal.
  • backupCorruptionTimeline.mjs is reachable through a documented named entry point, and the restoration runbook names when to run it.
  • A spec asserts the false-green case directly: a bundle with integrity[].status === 'empty' must not satisfy whatever predicate lastSuccessful uses. Written to fail against current dev.
  • Post-merge: on a populated deployment, a real backup run reports a verdict consistent with its artifact bytes.

Out of Scope

  • The persist-path root cause (#16208). This ticket does not fix why a subsystem exported zero; it fixes that nobody was told.
  • Making empty fatal. Explicitly rejected below.
  • Restore-path changes. restore.mjs reads bundle metadata too, but restore already fails loudly on missing artifacts; this ticket is about the reporting surfaces that claim health.
  • A general health-verdict audit. Five surfaces were observed reporting green over their own disproof during this incident. That pattern deserves its own treatment; this ticket fixes the one where being wrong costs the most.

Avoided Traps

  • Making empty fatal. Tempting and wrong: a genuinely fresh environment backs up empty, and a fatal verdict would break first-boot for every new deployment. The existing non-fatal choice is correct — the defect is that it is invisible, not that it is non-fatal.
  • Scheduling the timeline tool as the whole fix. A scheduled canary that nobody reads is the same failure one layer out. The health surface is what operators and agents actually consult, so the verdict has to reach there; the scheduled run is a supplement.
  • Trusting status: "success" as the contract. It means the local bundle completed, which is a real and useful fact. The fix must not redefine it — it must stop that field being the only thing a consumer sees.

Related

  • #16208 — the persist-path root cause that produced the empty export (Vega)
  • #16197 / #16238 — the same pattern in the orchestrator: a receipt computed every boot and read by nothing
  • #16221 / #16201 — backup-root relocation; prior work in this surface
  • #15639 — opt-in first-boot restore from latest backup bundle; a consumer that would inherit this correctness

Origin Session ID: 713db0da-2239-44ea-ba5b-931be90d34fc

Retrieval Hint: query_raw_memories("backup integrity empty verdict reaches no surface false-green health"), or the 2026-07-31 Chroma persist-path triage thread on #16208.


Amendment — 2026-08-01, ledger row 1 vs shipped contract

Raised in review of PR #16241: the Contract Ledger's row-1 fallback said a bundle with no readable integrity block is "reported as unverified, not as successful." The shipped code grandfathered it silently instead — eligible for lastSuccessful, with no marker.

The drift was real and directional: I wrote that ledger row before working through the grandfathering case and the code after.

Resolved by changing the code, not the row. The reviewer offered both and left the call to me; I initially took the text fix and was wrong to. The original row expressed the correct intent. HealthService now reports unverifiedCount alongside unusableCount, so a bundle can be eligible and declared unverified — which is what the row always said.

Why the text fix was the weaker answer. The receipt already carried this distinction as restorable: null. Dropping it at the health block left a computed verdict reaching one surface and not the other — this ticket's own defect, one layer in. Documenting that asymmetry rather than closing it would have shipped the exact thing #16240 was filed to end.

Correcting a claim I made in the first version of this amendment: I justified grandfathering with "retained series contain pre-block bundles" and cited a measurement in the same breath that contradicts it — 33 of 33 retained bundles carry an integrity block; 0 are pre-verdict. Grandfathering is right as a correctness principle (absent evidence must not become a failing verdict), not as an empirical claim about this deployment. I also argued a counter reading zero was "more surface for the same information"; that was wrong too — unverifiedCount: 0 is a positive assertion that everything reported was checked, and it becomes load-bearing the moment it is non-zero.

Live confirmation on real series: pre-Docker retained {count: 36, unusableCount: 4, unverifiedCount: 0}; the dockerized bind {lastSuccessful: null, lastCompleted: …, unusableCount: 1, unverifiedCount: 0}.

tobiu referenced in commit aa721ca - "fix(backup): a zero-row bundle is not a recovery source — carry the verdict to every surface (#16240) (#16241) on Aug 1, 2026, 3:29 AM
tobiu closed this issue on Aug 1, 2026, 3:29 AM