Context
Found while implementing #16510 (PR #16519's sibling, PR #16520). That ticket's own acceptance criterion — "a bundle with one populated collection and the rest empty must not return RESTORABLE" — turned out to create a data-loss path, and I withdrew it. This ticket is the honest version of the repair it was reaching for.
Nothing here is hypothetical: the failing sequence is derived from the shipped control flow in ai/scripts/maintenance/redeployPreflight.mjs, not inferred from a symptom.
The Problem
probeBundle produces one boolean, restorable, and evaluateRedeployPreconditions consumes it in two incompatible roles.
Role 1 — proof of prior state. redeployPreflight.mjs:127 pushes 'a verified restorable bundle' into priorEvidence, and :137 refuses --initialize when any prior evidence exists. :180 uses the same signal for PROCEED_MARKER_RECOVERED: a bundle proves a prior deployment even when the marker is gone, "otherwise a host that lost its marker independently of its bundles could never deploy again."
For this role, any populated collection is valid evidence. It answers "did a plane exist here?" — and the aggregate is exactly right.
Role 2 — authorization to proceed with a container-affecting redeploy. :168 returns PROCEED_VERIFIED with the reason "A verified, non-empty, restorable pre-transition bundle exists."
For this role, the aggregate is wrong. It answers "is there a usable recovery source if this redeploy goes bad?", and a bundle with a populated KB and five empty subsystems is not one.
One word, two questions. RESTORABLE currently means "at least one collection had rows", and role 2 reads it as "this plane can be recovered".
Why tightening the boolean is not the fix
The obvious repair — make partial bundles non-RESTORABLE — breaks role 1, and the failure is concrete:
host has a KB-only bundle and NO initialization marker
-> restorable false, so PROCEED_MARKER_RECOVERED (:180) never fires
-> falls to REFUSE_NO_VERIFIED_BUNDLE (:192), whose message reads
"No prior deployment is recorded and no usable bundle exists ...
If this is genuinely a first install, pass --initialize to say so."
-> the operator follows that instruction
-> priorEvidence is now EMPTY (the bundle no longer counts), so :159
returns PROCEED_INITIALIZING
-> a host holding a real KB backup is initialized overToday restorable === true is precisely what blocks that path, and the refusal message is what would walk the operator into it. The aggregate is load-bearing as a safety interlock.
The Architectural Reality
| Site |
Role |
Correct predicate |
redeployPreflight.mjs:127 (priorEvidence) |
prior-state proof |
aggregate — any rows anywhere |
redeployPreflight.mjs:180 (PROCEED_MARKER_RECOVERED) |
prior-state proof |
aggregate |
redeployPreflight.mjs:168 (PROCEED_VERIFIED) |
recovery-source authorization |
completeness |
PR #16520 already publishes the raw material — collectionCounts and emptyCollections land beside rowTotal on both verdict returns — deliberately without changing any predicate, because it was written while a live KB restore was in flight.
The Fix
Give role 2 its own field on the probe verdict rather than overloading restorable. Shape to settle during implementation, but the constraint set is fixed:
restorable keeps its current aggregate semantics — role 1 must not regress
- a distinct completeness fact authorizes role 2, derived from
emptyCollections
:168 reads the new field; :127 / :180 keep reading restorable
- the
REFUSE_NO_VERIFIED_BUNDLE message at :192 needs revisiting once role 2 can refuse independently — a refusal that says "no usable bundle exists" when one demonstrably does, and then recommends --initialize, is the sentence that makes the interlock break dangerous
Naming matters here and is part of the deliverable. restorable currently names role 2's question while implementing role 1's answer. Whatever the split lands on, neither field may keep a name that reads as the other's promise.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
probeBundle verdict |
this ticket |
gains a completeness field distinct from restorable |
absent ⇒ role 2 fails closed, never "fine" |
JSDoc |
spec: KB-only bundle authorizes role 1 and refuses role 2 |
restorable |
unchanged |
aggregate semantics preserved |
— |
in-source rationale (landed in PR #16520) |
red witness: the --initialize sequence above |
evaluateRedeployPreconditions |
this ticket |
:168 reads completeness; :127 / :180 read restorable |
— |
JSDoc |
existing preflight specs must stay green |
REFUSE_NO_VERIFIED_BUNDLE reason text |
this ticket |
must not claim absence when a partial bundle exists |
— |
in-source |
operator-facing string review |
Decision Record impact
none. No topology, backend posture, or config leaf. Reviewed ADR-0014 (cloud deployment topology / scheduler task taxonomy) as the nearest authority — this changes a maintenance probe's verdict shape, not deployment topology.
Acceptance Criteria
Out of Scope
- The per-collection reporting itself — landed in PR #16520.
- Deciding a "required subsystem set" as a policy list. The set was never the question; the role overload is.
#16486's authorizeActivation. It consumes a verdict and does not produce one. It will inherit whichever field role 2 lands on, but its own logic is untouched.
- Export-side completeness accounting — that is
#16516 / PR #16519, the opposite end of the same pipeline.
Avoided Traps
- Tightening the shared boolean. The obvious fix, and it converts a safety interlock into a data-loss path. This is the trap that cost
#16510 its acceptance criterion.
- Treating it as a policy question. My first framing, withdrawn: "which subsystems must be non-empty" sounds like taste and invites picking an answer, when the actual defect is that picking is the bug.
- Reading
emptyCollections as an authorization signal before the split. A consumer doing that recreates the interlock break from the outside; noted as post-merge validation on PR #16520.
Related
#16510 / PR #16520 — publishes the per-collection facts this builds on; its withdrawn AC is the origin of this ticket
#16516 / PR #16519 — export-side completeness accounting, same incident, opposite end
#16512 — @neo-opus-vega, healthcheck blessing an empty knowledge base
#16486 — the activation contract that consumes RESTORABLE
Live latest-open sweep: checked latest 20 open issues at 2026-08-04T21:10:28Z, plus a 200-issue title scan for restorab|redeploy|preflight|verdict; no equivalent found. A2A in-flight claim sweep: no overlapping [lane-claim].
Origin Session ID: eeacb603-97f1-4241-9b2f-3a542cab6d2c
Retrieval Hint: query_raw_memories("RESTORABLE two roles prior-state proof vs recovery authorization") · redeployPreflight priorEvidence --initialize interlock
Context
Found while implementing
#16510(PR #16519's sibling, PR #16520). That ticket's own acceptance criterion — "a bundle with one populated collection and the rest empty must not returnRESTORABLE" — turned out to create a data-loss path, and I withdrew it. This ticket is the honest version of the repair it was reaching for.Nothing here is hypothetical: the failing sequence is derived from the shipped control flow in
ai/scripts/maintenance/redeployPreflight.mjs, not inferred from a symptom.The Problem
probeBundleproduces one boolean,restorable, andevaluateRedeployPreconditionsconsumes it in two incompatible roles.Role 1 — proof of prior state.
redeployPreflight.mjs:127pushes'a verified restorable bundle'intopriorEvidence, and:137refuses--initializewhen any prior evidence exists.:180uses the same signal forPROCEED_MARKER_RECOVERED: a bundle proves a prior deployment even when the marker is gone, "otherwise a host that lost its marker independently of its bundles could never deploy again."For this role, any populated collection is valid evidence. It answers "did a plane exist here?" — and the aggregate is exactly right.
Role 2 — authorization to proceed with a container-affecting redeploy.
:168returnsPROCEED_VERIFIEDwith the reason "A verified, non-empty, restorable pre-transition bundle exists."For this role, the aggregate is wrong. It answers "is there a usable recovery source if this redeploy goes bad?", and a bundle with a populated KB and five empty subsystems is not one.
One word, two questions.
RESTORABLEcurrently means "at least one collection had rows", and role 2 reads it as "this plane can be recovered".Why tightening the boolean is not the fix
The obvious repair — make partial bundles non-
RESTORABLE— breaks role 1, and the failure is concrete:host has a KB-only bundle and NO initialization marker -> restorable false, so PROCEED_MARKER_RECOVERED (:180) never fires -> falls to REFUSE_NO_VERIFIED_BUNDLE (:192), whose message reads "No prior deployment is recorded and no usable bundle exists ... If this is genuinely a first install, pass --initialize to say so." -> the operator follows that instruction -> priorEvidence is now EMPTY (the bundle no longer counts), so :159 returns PROCEED_INITIALIZING -> a host holding a real KB backup is initialized overToday
restorable === trueis precisely what blocks that path, and the refusal message is what would walk the operator into it. The aggregate is load-bearing as a safety interlock.The Architectural Reality
redeployPreflight.mjs:127(priorEvidence)redeployPreflight.mjs:180(PROCEED_MARKER_RECOVERED)redeployPreflight.mjs:168(PROCEED_VERIFIED)PR #16520 already publishes the raw material —
collectionCountsandemptyCollectionsland besiderowTotalon both verdict returns — deliberately without changing any predicate, because it was written while a live KB restore was in flight.The Fix
Give role 2 its own field on the probe verdict rather than overloading
restorable. Shape to settle during implementation, but the constraint set is fixed:restorablekeeps its current aggregate semantics — role 1 must not regressemptyCollections:168reads the new field;:127/:180keep readingrestorableREFUSE_NO_VERIFIED_BUNDLEmessage at:192needs revisiting once role 2 can refuse independently — a refusal that says "no usable bundle exists" when one demonstrably does, and then recommends--initialize, is the sentence that makes the interlock break dangerousNaming matters here and is part of the deliverable.
restorablecurrently names role 2's question while implementing role 1's answer. Whatever the split lands on, neither field may keep a name that reads as the other's promise.Contract Ledger Matrix
probeBundleverdictrestorablerestorable--initializesequence aboveevaluateRedeployPreconditions:168reads completeness;:127/:180readrestorableREFUSE_NO_VERIFIED_BUNDLEreason textDecision Record impact
none. No topology, backend posture, or config leaf. Reviewed ADR-0014 (cloud deployment topology / scheduler task taxonomy) as the nearest authority — this changes a maintenance probe's verdict shape, not deployment topology.Acceptance Criteria
--initializeis refused, andPROCEED_MARKER_RECOVEREDstill fires when the marker is missing. Red witness required — this is the regression the naive fix introduces, and a spec that does not fail before the fix has not tested it.PROCEED_VERIFIEDis refused, naming which subsystems are empty.BUNDLE_EMPTY, unregressed.REFUSE_NO_VERIFIED_BUNDLEmessage no longer asserts that no bundle exists when a partial one does, and no longer recommends--initializein that state.Out of Scope
#16486'sauthorizeActivation. It consumes a verdict and does not produce one. It will inherit whichever field role 2 lands on, but its own logic is untouched.#16516/ PR #16519, the opposite end of the same pipeline.Avoided Traps
#16510its acceptance criterion.emptyCollectionsas an authorization signal before the split. A consumer doing that recreates the interlock break from the outside; noted as post-merge validation on PR #16520.Related
#16510/ PR #16520 — publishes the per-collection facts this builds on; its withdrawn AC is the origin of this ticket#16516/ PR #16519 — export-side completeness accounting, same incident, opposite end#16512— @neo-opus-vega, healthcheck blessing an empty knowledge base#16486— the activation contract that consumesRESTORABLELive latest-open sweep: checked latest 20 open issues at
2026-08-04T21:10:28Z, plus a 200-issue title scan forrestorab|redeploy|preflight|verdict; no equivalent found. A2A in-flight claim sweep: no overlapping[lane-claim].Origin Session ID: eeacb603-97f1-4241-9b2f-3a542cab6d2c
Retrieval Hint:
query_raw_memories("RESTORABLE two roles prior-state proof vs recovery authorization")·redeployPreflight priorEvidence --initialize interlock