Split out of #16452 per pull-request-workflow.md §9.1, which requires an honest delivered close target and offers exactly this escape: "add the honest delivered leaf close target or split/file the narrow ticket". #16452 asks for the activation kernel to be the only mutation path, enforced. Enforcement needs two halves — a contract that can refuse, and wiring that routes every mutation through it. This leaf is the first half. #16452 stays open and owns the second.
Not a micro-ticket and not an A+FU split of one deliverable: the contract's vocabulary and decision semantics are the part a reviewer must be free to change, and they are much cheaper to change before a deploy path depends on them than after.
The Problem
ai/scripts/maintenance/redeployPreflight.mjs decides correctly and then returns. It leaves no artifact behind, so nothing downstream can distinguish:
the preflight ran and passed, from
the preflight was never run at all
Both present as "no refusal happened". A mutation path that simply does not call it therefore inherits no protection — which is what makes a reachable guard a bypassable one, the exact distinction #16055 established and #16452 exists to close.
The incident behind this whole line of work was a destructive path that proceeded because nothing positively said no. Absence of proof has to be the refusal.
The Architectural Reality
redeployPreflight.mjs:18 already gates on a "verified, non-empty, restorable" bundle, and evaluateRedeployPreconditions is already a pure truth table with IO held at the edges. The shape to match exists; what is missing is a durable output.
REDEPLOY_PREFLIGHT_DECISION carries several PROCEED_* variants, and they are not interchangeable for this purpose. PROCEED_INITIALIZING authorizes a declared first install — it asserts the opposite of what an activation receipt must: that there is nothing to restore. Only a RESTORABLE verdict can carry an activation.
The selection sub (#16450) hands over a bound stageReceiptId. This leaf must treat it as an opaque identifier — the binding check is a comparison, and reaching into that module's internals would be a layering violation and a false dependency.
The Fix
A pure, IO-free decision module: ai/services/shared/activationReceipt.mjs, sibling to captureReceipt.mjs.
Target Surface
Source of Authority
Proposed Behavior
Fallback / Error Semantics
Docs
Evidence
decision vocabulary
this ticket
authorize | refuse, frozen
No value means "undecided"
module JSDoc
closure test over generated cross-product
refusal reasons
this ticket
Frozen, distinct per cause
Unknown cause is impossible by construction
module JSDoc
every refusal's reason asserted in-vocabulary
receipt binding
this ticket
Binds stageReceiptIdand resolved container config
Mismatch on either refuses
module JSDoc
spec per binding
ordering
this ticket
Pre-mutation check precedes freshness
Post-mutation receipt refuses as such, never as stale
module JSDoc
spec pins the precedence
The receipt binds the resolved CONTAINER CONFIG, not the image digest.docker compose freezes healthcheck, command and env into the container at create time, so an activation that rebuilds images at the target revision and leaves containers in place moves the code and leaves the contract behind. Measured on neo-local-canonical after PR #16465 merged: image revision label current, merged code present inside the image, merged contract present in the checkout — and the running container's healthcheck predating the change, because the container was created before it. A digest-bound receipt is satisfied by exactly that half-applied state.
Acceptance Criteria
The decision function returns only authorize or refuse, proven over a generated input cross-product rather than a hand-written case list — an enumerated list proves only that the enumerated cases are closed, which is where a third state hides.
The authorizing set is asserted exactly and by name, so a newly-authorizing combination fails loudly instead of shifting a count.
Every refusal carries a reason from the frozen vocabulary; no ad-hoc strings, no nulls.
A missing, malformed, or non-object receipt refuses.
A non-RESTORABLE verdict refuses, and PROCEED_INITIALIZING is witnessed as one of them.
A receipt observed at or after the first mutation refuses as receipt-not-pre-mutation, including at the exact instant, and including when it is also stale — the precedence is pinned, because a staleness-first report makes the operator fix "re-run the preflight", which mints a fresh receipt that is still post-mutation.
A stale receipt refuses, and so does a future-dated one.
A stageReceiptId mismatch refuses; a resolved-container-config mismatch refuses.
Mutation-verified: introducing a third decision value fails the closure test.
Out of Scope
Wiring. Emitting receipts from redeployPreflight and consuming them in the reference deploy transaction stay with #16452, which is what "enforced" means there.
The docker inspect digest helper.targetConfigDigest is consumed as an opaque string here.
Preflight soundness.#16404 owns whether the non-emptiness judgement is correct; this leaf enforces where authority lives, not the quality of its input.
Accepting any PROCEED_* as sufficient.PROCEED_INITIALIZING would authorize mutating a plane with no bundle.
Binding the image digest. Satisfied by the half-applied state measured above; it admits the third state through the binding rather than through the decision.
A hand-written closure test. It proves the author's imagination is closed, not the function.
Reporting staleness before pre-mutation ordering. See AC above — the "obvious" order produces a second attempt that authorizes an already-touched plane.
Retrieval Hint: query_raw_memories("activation receipt closure two states never three container config not image digest pre-mutation before freshness")
Refs #16452
Context
Split out of #16452 per
pull-request-workflow.md §9.1, which requires an honest delivered close target and offers exactly this escape: "add the honest delivered leaf close target or split/file the narrow ticket". #16452 asks for the activation kernel to be the only mutation path, enforced. Enforcement needs two halves — a contract that can refuse, and wiring that routes every mutation through it. This leaf is the first half. #16452 stays open and owns the second.Not a micro-ticket and not an A+FU split of one deliverable: the contract's vocabulary and decision semantics are the part a reviewer must be free to change, and they are much cheaper to change before a deploy path depends on them than after.
The Problem
ai/scripts/maintenance/redeployPreflight.mjsdecides correctly and then returns. It leaves no artifact behind, so nothing downstream can distinguish:Both present as "no refusal happened". A mutation path that simply does not call it therefore inherits no protection — which is what makes a reachable guard a bypassable one, the exact distinction #16055 established and #16452 exists to close.
The incident behind this whole line of work was a destructive path that proceeded because nothing positively said no. Absence of proof has to be the refusal.
The Architectural Reality
redeployPreflight.mjs:18already gates on a "verified, non-empty, restorable" bundle, andevaluateRedeployPreconditionsis already a pure truth table with IO held at the edges. The shape to match exists; what is missing is a durable output.REDEPLOY_PREFLIGHT_DECISIONcarries severalPROCEED_*variants, and they are not interchangeable for this purpose.PROCEED_INITIALIZINGauthorizes a declared first install — it asserts the opposite of what an activation receipt must: that there is nothing to restore. Only aRESTORABLEverdict can carry an activation.stageReceiptId. This leaf must treat it as an opaque identifier — the binding check is a comparison, and reaching into that module's internals would be a layering violation and a false dependency.The Fix
A pure, IO-free decision module:
ai/services/shared/activationReceipt.mjs, sibling tocaptureReceipt.mjs.authorize|refuse, frozenstageReceiptIdand resolved container configThe receipt binds the resolved CONTAINER CONFIG, not the image digest.
docker composefreezeshealthcheck,commandand env into the container at create time, so an activation that rebuilds images at the target revision and leaves containers in place moves the code and leaves the contract behind. Measured onneo-local-canonicalafter PR #16465 merged: image revision label current, merged code present inside the image, merged contract present in the checkout — and the running container's healthcheck predating the change, because the container was created before it. A digest-bound receipt is satisfied by exactly that half-applied state.Acceptance Criteria
authorizeorrefuse, proven over a generated input cross-product rather than a hand-written case list — an enumerated list proves only that the enumerated cases are closed, which is where a third state hides.RESTORABLEverdict refuses, andPROCEED_INITIALIZINGis witnessed as one of them.receipt-not-pre-mutation, including at the exact instant, and including when it is also stale — the precedence is pinned, because a staleness-first report makes the operator fix "re-run the preflight", which mints a fresh receipt that is still post-mutation.stageReceiptIdmismatch refuses; a resolved-container-config mismatch refuses.Out of Scope
redeployPreflightand consuming them in the reference deploy transaction stay with #16452, which is what "enforced" means there.docker inspectdigest helper.targetConfigDigestis consumed as an opaque string here.Avoided Traps
PROCEED_*as sufficient.PROCEED_INITIALIZINGwould authorize mutating a plane with no bundle.Related
Origin Session ID: eeacb603-97f1-4241-9b2f-3a542cab6d2c
Retrieval Hint:
query_raw_memories("activation receipt closure two states never three container config not image digest pre-mutation before freshness")