LearnNewsExamplesServices
Frontmatter
id16510
titleA restorability verdict sums per-collection rows, so one populated collection vouches for five empty ones
stateClosed
labels
bugai
assigneesneo-opus-ada
createdAtAug 4, 2026, 7:36 PM
updatedAtAug 5, 2026, 12:30 PM
githubUrlhttps://github.com/neomjs/neo/issues/16510
authorneo-opus-ada
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 5, 2026, 12:30 PM

A restorability verdict sums per-collection rows, so one populated collection vouches for five empty ones

neo-opus-ada
neo-opus-ada commented on Aug 4, 2026, 7:36 PM

Context

Measured by @neo-opus-vega while working the deployment lane: redeployPreflight blessed a 112MB KB-export-only artifact as RESTORABLE, while a bundle-meta-absent condition was the real disqualification. She handed it to me because it undermines the activation contract I merged in #16486 / PR #16483; I verified the mechanism and the boundary before filing rather than taking it on report.

Boundary check first, because the obvious home is wrong. #16404 ("a zero-row export cannot say whether the corpus was empty or gone") is CLOSED, and its repair landed on the capture side — captureReceipt.mjs's per-source provenEmpty. This is a probe-side defect in a different proposition. #16404 asked can a zero distinguish empty-from-gone; this asks can a non-zero SUM distinguish complete-from-partial. Same family, different layer, and re-opening #16404 would have buried it under a closed ticket's semantics.

Live sweep of open issues for bundle/restore/preflight/probe owners: #15693 (restore-delta-merge), #15639 (first-boot restore), #15527 (FM clean-consumer probe) — none owns the probe's verdict aggregation.

The Problem

ai/scripts/maintenance/restore.mjs, probeBundle:

rowTotal = Object.values(meta?.streamedCounts ?? {}).reduce((sum, count) => sum + count, 0);

if (rowTotal === 0) {
    return {restorable: false, code: 'BUNDLE_EMPTY', …}
}

return {restorable: true, code: 'RESTORABLE', …}

streamedCounts is per-collection (:592streamedCounts[collection] = (…) + 1). rowTotal flattens it to one number, and the only emptiness test is against that aggregate.

So a bundle whose KB export is large and whose other five subsystems are empty returns RESTORABLE. One populated collection vouches for every collection it says nothing about. A count is not a corpus.

The code's own comment shows the previous fix stopping one step short:

"A bundle carrying only the six required directories and a minimal meta parsed clean and returned RESTORABLE — the ticket's explicitly forbidden precondition… Vector-collection rows are the measure because they ARE the recovery payload."

That correctly rejected the all-empty bundle. It did not reject the partially-empty one, because summing was the chosen instrument and a sum cannot express per-member absence.

The Architectural Reality

  • This is the exact conflation #16404 was opened to break, one layer downstream. That work established the principle — record facts per source, derive one claim, never collapse — and implemented it in captureReceipt.mjs. The probe never adopted it.
  • It undermines a merged contract. #16486's authorizeActivation refuses everything unless the receipt carries verdictCode === 'RESTORABLE'. With this aggregation, RESTORABLE means "at least one collection had rows", not "this plane can be restored". A two-state gate that cannot be tricked, guarding a producer that hands it a lie — strictly worse than the gap the gate was built for, because the receipt now carries proof-shaped authority.
  • bundleIntegrity.mjs already classifies per-subsystem (emptySubsystems, status: 'empty'). The vocabulary for the correct answer exists; the probe does not consume it.

The Fix

Decide restorability per subsystem, and derive the bundle verdict from those facts rather than from their sum.

Target Surface Source of Authority Proposed Behavior Fallback / Error Semantics Docs Evidence
probeBundle verdict payload this ticket Carries collectionCounts + emptyCollections beside rowTotal Absent breakdown ⇒ consumer sees {} / [], never a synthesized "fine" JSDoc spec: KB-populated + others empty names the empties
restorable boolean unchanged by this ticket Keeps keying on the aggregate in-source rationale see the correction below
verdict role split separate ticket RESTORABLE serves prior-state proof AND recovery-source authorization; role 2 needs its own field changes a consumed surface (redeployPreflight)

CORRECTED 2026-08-04 — the strikethrough rows below were WRONG and are retained so the error stays visible. Original ledger rows: "Any required subsystem empty ⇒ not RESTORABLE" and "required-subsystem set | open question | … must be decided, not defaulted", plus the framing "The open question is genuine and should not be answered by the implementer alone … That is a policy call about what 'restorable' promises."

It is not a policy call. evaluateRedeployPreconditions reads this verdict in two incompatible roles — proof of prior state (redeployPreflight.mjs:127, feeding the priorEvidence set that refuses --initialize) and authorization to proceed with a container-affecting redeploy (:168). Tightening the shared boolean breaks the first to serve the second, and the failure is concrete: a host with a KB-only bundle and no marker falls to REFUSE_NO_VERIFIED_BUNDLE (:192), whose message tells the operator to pass --initialize — which then PROCEEDS, because the bundle no longer counts as prior evidence. A host holding a real KB backup gets initialized over. Full derivation: the correction comment on this ticket.

Acceptance Criteria

  • The verdict names which subsystems were empty — a refusal a consumer cannot act on is a weaker artifact than the sum it replaced.
  • The verdict carries the per-collection breakdown (collectionCounts), not only rowTotal.
  • The all-empty case still returns BUNDLE_EMPTY — the existing repair is preserved, not regressed.
  • A genuinely complete bundle still returns RESTORABLE. Positive control: without it, "refuse everything" passes every assertion above.
  • restorable is unchanged, and the reason is recorded in-source as a mechanism (the two-role interlock) rather than as an unsettled preference.
  • #16486's authorizeActivation is unchanged — it consumes a verdict and does not produce one; this ticket repairs the producer.
  • A bundle with one populated collection and the rest empty does not return RESTORABLEwithdrawn, see the correction above. Implementing this as written creates the data-loss path; the honest version is the role split, tracked separately.
  • The required-subsystem set is explicitly decided and recordedwithdrawn. The set was never the question.

Out of Scope

  • #16486 / the activation contract. It correctly demands RESTORABLE; the defect is what that word means. No change there.
  • #16404. CLOSED, capture-side, and its provenEmpty semantics are not modified.
  • Restore behaviour itself. This is the read-only probe's verdict, not the restore path.

Avoided Traps

  • Re-opening #16404. Adjacent family, different proposition, and its closed semantics would have absorbed this one.
  • Defaulting the required-subsystem set to "all six" because it is the safe-looking answer. That is a policy decision wearing an implementation disguise.
  • Fixing the sum (e.g. a minimum threshold). A larger sum is still a sum; the defect is the collapse, not the constant.

Related

  • #16486 / PR #16483 (the consumer this protects) · #16404 (CLOSED, capture-side precedent) · #16442 (the per-source receipt pattern to mirror) · #16452 (activation enforcement lane)

Origin Session ID: eeacb603-97f1-4241-9b2f-3a542cab6d2c

Retrieval Hint: query_raw_memories("restorable verdict sums per-collection rows one populated collection vouches for empty ones probe aggregation")

tobiu referenced in commit 204c842 - "fix(ai): the restorability verdict reports per-collection facts, not only their sum (#16510) (#16520) on Aug 5, 2026, 12:30 PM
tobiu closed this issue on Aug 5, 2026, 12:30 PM