spec: console release defences rev 2, Astra round 1 answered
Astra round 1 rejected rev 1 with nine findings, committed unedited. All nine are accepted. D1 now verifies the unit's ExecStart binding before judging a restart, tracks the new invocation by InvocationID, and on a rollback restarts the unit to replace a running rejected process. D3 hashes running and staged bytes instead of trusting manifests, re-asks for a mint until the proof appears, and reads its policy from host.env on every pass. D2 lets a build back in if it ran well on this box before. The rollout is reordered: bindings first, dev proving before flagship, and a rehearsal that really exercises D2 on the box.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+216
−85
docs/superpowers/specs/2026-10-06-console-release-defences-design.md+197 −85docs/superpowers/specs/CODEX-FINDINGS-console-release-defences.md+19 −0
CI passed — run details
diff --git a/docs/superpowers/specs/2026-10-06-console-release-defences-design.md b/docs/superpowers/specs/2026-10-06-console-release-defences-design.md| index dc8200c55..8d4f39d84 100644 |
| --- a/docs/superpowers/specs/2026-10-06-console-release-defences-design.md |
| +++ b/docs/superpowers/specs/2026-10-06-console-release-defences-design.md |
| @@ -1,6 +1,10 @@ |
| # Console release defences |
| -Status: draft rev 1, 2026-10-06. Branch `design-console-release-defences`. |
| +Status: draft rev 2, 2026-10-06. Branch `design-console-release-defences`. |
| +Rev 2 answers Astra round 1 (`CODEX-FINDINGS-console-release-defences.md`, |
| +verdict reject, nine findings). All nine were accepted, and each fix is marked |
| +**(codex N)** in the text: 1 §2.3, 2 §2.2, 3 §4.2, 4 §2.3, 5 §4.3, 6 §4.5, |
| +7 §3.2, 8 §5.2 and §6, 9 §6. |
| ## 0. Why |
| @@ -80,25 +84,51 @@ repoints back to `cur`, restores capability drop-ins, and starts the unit on |
| the restored build (`WithStartUnit`). D1 makes the named-unit arm return an |
| honest error. |
| -### 2.2 The settle check |
| +### 2.2 The binding comes first (codex 2) |
| + |
| +The console is one unit behind several hostnames, and adoption is per host in |
| +`ORDER BY host` (`internal/registry/registry.go:800`). Each `adoptHost` |
| +repoints its own `live/<host>` and then restarts. The unit runs whichever link |
| +its `ExecStart` names: on flagship that is `live/platform.carlosframework.com`, |
| +which sorts **last** of the three. A restart fired from an earlier host runs |
| +the old build. The console-as-an-app spec already requires that the one |
| +`Unit`-stamped route be the host `ExecStart` names (2026-08-23 design §4c), and |
| +`roll-console.sh`'s final `systemctl restart` is what has hidden it when it |
| +isn't. |
| + |
| +So D1 judges a restart only when the binding is verified. Before the |
| +settle check, the named-unit arm reads `ExecStart` (`systemctl show -p |
| +ExecStart <unit>`). Its executable path must be `live/<rt.Host>` for this |
| +route. If it is not, the arm logs `binding: <unit> runs live/<X>, but the unit |
| +stamp is on <rt.Host>; restart not checked`, does today's plain try-restart, |
| +and returns nil. That is no worse than today. Rollout step 1 (§6) verifies the |
| +binding on every console box and restamps where it is wrong, and |
| +`roll-console.sh` keeps its final restart until the binding check passes on |
| +that box. |
| + |
| +### 2.3 The settle check |
| + |
| +After `systemctl try-restart <unit>` returns 0 on a verified binding, the |
| +named-unit arm waits for the unit to **settle**: |
| -After `systemctl try-restart <unit>` returns 0, the named-unit arm waits for |
| -the unit to **settle**: |
| - |
| -- Record the unit's `NRestarts` before the restart. |
| - Poll every 500 ms, up to **30 s** (`CARLOS_UNIT_SETTLE`, a duration, floor |
| - 5 s), reading `ActiveState`, `SubState`, `MainPID` and `NRestarts` from |
| + 5 s), reading `ActiveState`, `InvocationID`, `MainPID` and `NRestarts` from |
| `systemctl show`. |
| -- **Healthy** when, on one poll, `ActiveState=active`, `NRestarts` is |
| - unchanged, the MainPID's `/proc/<pid>/exe` resolves into the new version's |
| - directory, and the route's readiness check passes (§2.3). Return nil |
| - immediately. The console takes about 8 s, so a healthy restart costs about |
| - 8 s, not 30. |
| -- **Failed** as soon as `ActiveState` is `failed`, or `NRestarts` has moved, |
| - or the MainPID has changed since the first active poll (a restart loop that |
| - happens to be caught while active). Return an error carrying the state and |
| - the last 20 lines of `journalctl -u <unit> -o cat`. |
| -- **Failed** at the deadline if the unit never satisfied "healthy". |
| +- The first poll that finds the unit `active` with an `InvocationID` |
| + different from the one before the restart fixes **the new invocation**. |
| + Everything after is judged against it, never against the previous |
| + invocation's counters: systemd resets `NRestarts` on a manual start, so a |
| + before-and-after comparison misreads healthy restarts (codex 4). |
| +- **Healthy** when, within the new invocation, `ActiveState=active`, the |
| + MainPID's `/proc/<pid>/exe` resolves into the new version's directory, and |
| + the route's readiness check passes (§2.4). Return nil immediately. The |
| + console takes about 8 s, so a healthy restart costs about 8 s, not 30. |
| +- **Failed** as soon as `ActiveState` is `failed`, or the `InvocationID` |
| + changes again (systemd restarted it), or `NRestarts` within the new |
| + invocation goes above zero. Return an error carrying the state and the last |
| + 20 lines of `journalctl -u <unit> -o cat`. |
| +- **Failed** at the deadline if the unit never satisfied "healthy". That |
| + includes a process that is still running but never became ready. |
| A unit that was **not running** before the restart (an operator stopped it) |
| is not checked: `try-restart` started nothing, and that stays a no-op. |
| @@ -106,19 +136,29 @@ is not checked: `try-restart` started nothing, and that stays a no-op. |
| The check runs inside the root stage pass and makes it up to 30 s longer |
| (about 8 s for a healthy console). A stage request written while that pass |
| runs past the relay's 15 s window waits for the next trigger: the known gap |
| -recorded with the deploy-pickup fix. Three console hosts share one unit, but |
| -only the first host's restart is checked against a new build. If it fails, |
| -`poisoned` makes the other two hosts skip. If it passes, their restarts settle |
| -in the time it takes the console to start again. |
| - |
| -On a failure the existing `adoptHost` branch then repoints and starts the |
| -unit on `cur`. Before that start, the start callback (`makeStartRoute`) runs |
| -`systemctl reset-failed <unit>`, because a unit parked by its start limit |
| -refuses `start` until the counter is cleared. The rollback start gets the |
| -same settle check, and its failure is logged as "rollback did not come up |
| -either", which is the one case still needing a person (§2.5 alerts it). |
| - |
| -### 2.3 Readiness |
| +recorded with the deploy-pickup fix. Only the bound route (§2.2) carries the |
| +`Unit` stamp, so there is one checked restart per unit per pass. Hosts that |
| +sort after it skip a rejected version through `poisoned`, and hosts before it |
| +are repointed by the rollback below. |
| + |
| +On a failure the existing `adoptHost` branch then repoints to `cur`. The |
| +rollback must **replace** whatever is running, not merely start (codex 1). A |
| +deadline failure can leave the rejected build running and unready, and |
| +`systemctl start` does nothing to a running unit (`internal/host/agent.go:3208`). |
| +So for a named unit that D1 rejected, the rollback runs `systemctl |
| +reset-failed <unit>`, which clears a start limit, then `systemctl restart |
| +<unit>`. Every other caller of `makeStartRoute` keeps `start`. The rollback |
| +gets the same settle check against `cur`'s directory. Its failure is logged |
| +as "rollback did not come up either", the one case that still needs a person |
| +(§2.6 alerts it). |
| + |
| +Hosts earlier in the pass may already have repointed their links to the |
| +rejected version (they sort before the bound host). The rollback repoints |
| +every route on this box whose live link names the rejected version of the same |
| +app back to `cur`. That keeps their `X-Carlos-Version` honest, which today |
| +depends on alphabetical order (2026-08-23 design §4c). |
| + |
| +### 2.4 Readiness |
| In order: |
| @@ -131,7 +171,7 @@ The three console routes get `{"path":"/healthz","status":200}` as part of |
| rollout (§6). The console's `/healthz` (`internal/console/server.go`) is |
| unauthenticated and answers as soon as the listener is up. |
| -### 2.4 Refusing the build until something changes |
| +### 2.5 Refusing the build until something changes |
| `poisoned` lives for one pass. Without more, the next pass, and the agent's |
| 2 s fast loop asking for a root stage pass every 15 s, would re-adopt the same |
| @@ -158,7 +198,7 @@ is the explicit retry, and needs nothing new. |
| The record keys on the artifact sha256 read from the manifest, not on the |
| version name, because names get reused. |
| -### 2.5 Being seen |
| +### 2.6 Being seen |
| - A rollback writes a line naming both versions, the reason and the journal |
| tail to the root pass's output (journal). |
| @@ -223,10 +263,17 @@ build is …/cmd/console — not pointed live (ship with --program-change if thi |
| is intended) |
| ``` |
| -and write a refusal record (§2.4) so the agent does not relay it again. This |
| +and write a refusal record (§2.5) so the agent does not relay it again. This |
| alone would have stopped today's outage on the box, with no service |
| interruption, even with the bad manifest already promoted. |
| +**Going back across a deliberate change (codex 7).** After an intended A→B |
| +change, promoting an old A release back would be refused, because A's |
| +immutable manifest has no `program_change`. So the check is skipped when the |
| +staged binary's sha256 is in this box's healthy history (§4.5): a build that |
| +ran well here before is never a wrong program. The history is kept for every |
| +named-unit route, not only D3-listed apps, so D2 can use it everywhere. |
| + |
| ## 4. D3: the flagship console adopts only builds proven on dev |
| ### 4.1 Shape |
| @@ -252,6 +299,18 @@ It binds app, platform and artifact sha256. `version` is informational. The |
| target compares sha256, so flagship must run **the same bytes** dev ran |
| (§5.2), and a version name reused for different bytes has no proof. |
| +**Every sha256 in D3 is hashed from bytes, never copied from a manifest |
| +(codex 3).** A version name can be reused after pruning |
| +(`internal/release/release.go:632`). An already-staged binary skips the |
| +download check (`internal/release/adopt.go:1317`), and `backfillManifest` |
| +fetches whatever manifest the bucket now holds for that name without |
| +comparing it to the staged file (`internal/release/adopt.go:1402`). So a |
| +manifest's sha256 can name bytes the box is not running. Minting hashes the |
| +running process's executable (`/proc/<MainPID>/exe`). The requiring box |
| +hashes the staged file it is about to point live, on a cache hit too, and the |
| +healthy history records the hash of the executable that passed the settle |
| +check. |
| + |
| The proving key is a fresh ed25519 key, separate from both release keys, held |
| only by the proving console box as a root-only file. `carlos proof keygen` |
| writes the private half and prints the public half. |
| @@ -265,11 +324,12 @@ Dev's console box host.env gains `CARLOS_PROVE_APPS=console`, |
| A root pass mints a proof for a named-unit route of a listed app when all of |
| these hold at once: |
| -- the live link names version V, whose staged manifest gives sha256 S for |
| - this box's platform; |
| -- the unit is `active`, its MainPID's exe resolves into V's directory, and its |
| - `ActiveEnterTimestamp` is at least `CARLOS_PROVE_AFTER` ago; |
| -- the route's readiness check (§2.3) passes now; |
| +- the route's binding is verified (§2.2), and the unit is `active` with an |
| + `ActiveEnterTimestamp` at least `CARLOS_PROVE_AFTER` ago and the same |
| + `InvocationID` throughout; |
| +- S is the sha256 of the bytes of `/proc/<MainPID>/exe`, and the live link |
| + resolves to that same file; |
| +- the route's readiness check (§2.4) passes now; |
| - no refusal record names S; |
| - no proof for S exists yet in dev's bucket. |
| @@ -278,9 +338,12 @@ It writes `proofs/<app>/<sha256>.json` to dev's own bucket. |
| Minting needs root (the key), and root passes run hourly or when the agent |
| asks. So the agent asks: on its fast loop, when a listed app's route has been |
| live and active for `CARLOS_PROVE_AFTER` with no proof in the bucket, it |
| -requests a root pass through the existing stage-request relay, at most once |
| -per build. A proof is then minted within a minute or so of the bake period |
| -ending. |
| +requests a root pass through the existing stage-request relay. It **keeps |
| +asking every 2 minutes until it sees the proof in the bucket** (codex 5). The |
| +relay can miss a request written while `carlos-stage.service` is already |
| +running (`internal/host/agent.go:1203`), and a mint write can fail. A proof is |
| +then normally minted within a minute or two of the bake period ending, and at |
| +worst within the hourly pass. |
| Dev's bucket layout and IAM already let the box write under its own prefix; |
| the plan confirms the exact prefix the box role may write. |
| @@ -303,19 +366,26 @@ Flagship's console box host.env gains |
| `CARLOS_REQUIRE_PROOF=console:dev:<base64 ed25519 public key>`, a |
| comma-separated list of `app:prover:key` entries. |
| -In `adoptHost`, for a route whose app is listed, **after reading the new |
| -manifest and before `ensureStaged`** (so an unproven build is neither |
| -downloaded nor relayed to root), the box takes the manifest's sha256 for its |
| -platform and looks for `apps/<acct>/<app>/proofs/<sha256>/<prover>.json`. It |
| -proceeds if the proof parses, its signature verifies against the configured |
| -key, and its `app`, `platform` and `sha256` match. |
| +In `adoptHost`, for a route whose app is listed, the check runs twice: |
| + |
| +1. **A cheap gate, after reading the new manifest and before |
| + `ensureStaged`,** so an unproven build is neither downloaded nor relayed to |
| + root. The box takes the manifest's sha256 for its platform and looks for |
| + `apps/<acct>/<app>/proofs/<sha256>/<prover>.json`. It continues if the |
| + proof parses, its signature verifies against the configured key, and its |
| + `app`, `platform` and `sha256` match. |
| +2. **The binding check, after `ensureStaged` and before `PointLive`.** The box |
| + hashes the staged file and requires that hash to equal the proof's |
| + sha256, or to be in the healthy history. A mismatch means the staged bytes |
| + are not the proven ones (a reused name, a stale cache). The box refuses |
| + the version with a refusal record (§2.5) naming both hashes. |
| It also proceeds, with no proof, if the sha256 is in the box's own **healthy |
| history**: a record in the bake directory of builds this box has run well. A |
| root pass adds a sha256 when D1's settle check passes for it, and also when it |
| -finds a listed app's live build meeting §4.3's first three mint conditions |
| -(active, the right exe, ready, for `CARLOS_PROVE_AFTER`; default 15m on a |
| -requiring box too). The second rule is what puts the build already live when |
| +finds any named-unit route's live build meeting §4.3's first three mint |
| +conditions (bound, active in one invocation for `CARLOS_PROVE_AFTER`, the |
| +hashed exe, ready; default 15m when unset). The second rule is what puts the build already live when |
| the requirement is switched on into the history without a restart. So rolling |
| back to a build flagship ran before, as this morning's recovery did, never |
| waits on dev. |
| @@ -329,10 +399,22 @@ once the build is live. |
| `carlos status --live` shows "waiting for a dev proof" for that host. |
| -**Break glass:** remove the `CARLOS_REQUIRE_PROOF` line from host.env as root. |
| -Each pass that runs without it while the box has a healthy history for a |
| -listed app logs once an hour that the requirement is off. Nothing in the |
| -bucket or the CLI can switch it off. |
| +**Where the setting is read (codex 6).** The agent gets host.env through |
| +`EnvironmentFile` only when it starts (`internal/host/units/carlos-edge.service:18`), |
| +while each root pass starts fresh (`carlos-stage.service:30`). An edit would |
| +reach one process and not the other, and restarting `carlos-edge` takes the |
| +box's edge down. So the four `PROVE` variables and `CARLOS_REQUIRE_PROOF` are |
| +**read from the file `/etc/carlos/host.env` at the start of every pass** by |
| +both the agent and root, not from the process environment. A small parser |
| +reads only those keys. The file is root-owned and world-readable today; the |
| +plan confirms that the sandboxed agent can read it. |
| + |
| +**Break glass:** remove the `CARLOS_REQUIRE_PROOF` line from host.env as root, |
| +then `touch` the stage-request path (or run `carlos adopt --stage-only`) so a |
| +root pass adopts at once. The agent's next tick also sees the line gone. |
| +While the requirement is off on a box that has a healthy history for an app, |
| +the box logs once an hour that the requirement is off. Nothing in the bucket |
| +or the CLI can switch it off. |
| ### 4.6 Who sees none of this |
| @@ -366,8 +448,15 @@ other files. Step 4's "eight files" becomes ten. |
| - **`flagship` runs `proof get` against dev first** and refuses to ship if |
| there is none, naming how long dev has had the build. With a proof it ships, |
| promotes, runs `proof put` and verifies as today. |
| -- **The adopt and restart SSM steps go.** Adoption restarts the unit and D1 |
| - checks it. The `/proc/<pid>/exe` verification stays. |
| +- **The adopt and restart SSM steps go once the box's binding is verified** |
| + (§2.2): the script reads the binding line from the box first, and keeps its |
| + final `systemctl restart` on any box where the binding is wrong. Otherwise |
| + adoption restarts the unit and D1 checks it. The `/proc/<pid>/exe` |
| + verification stays. |
| +- **A `--bootstrap` flag for the first flagship roll only** skips the proof |
| + step, for the one roll that installs D3 before dev can mint (§6). It prints |
| + that it is skipping. It does not exist on the box: flagship's requirement is |
| + switched on only after that roll. |
| The recipe's console step reads: roll the release's console to dev, wait for |
| the proof (15 minutes), roll it to flagship. It says in so many words that |
| @@ -378,23 +467,36 @@ that now, but the recipe should not lean on it. |
| Order matters: the box binary is what checks, so it goes first. |
| -1. Ship D1, D2 and D3 in one platform release. With no config, D3 is inert. |
| -2. Set the health probe on the three flagship console routes and dev's |
| - console route. |
| -3. Roll the release's boxes (`carlos system update`), then the console with |
| - the new recipe. That first console roll is the D1 and D2 live check: watch |
| - for the settle line in the journal. |
| -4. On dev: `carlos proof keygen` on the console box, set the four `PROVE` |
| - variables, wait for the current dev console's proof to appear. |
| -5. Rehearse on dev: ship the public `carlos-linux-arm64` as a console release |
| - with `--program-change`, promote it, and confirm D2's box check refuses it. |
| - Then ship a console built to exit 2 on start, with D2 satisfied, and |
| - confirm D1 rolls back within 30 s, writes a refusal, raises the alert, and |
| - that a re-promote retries. Delete both releases. |
| -6. On flagship: set `CARLOS_REQUIRE_PROOF` with dev's public key. The live |
| - build is unaffected (the requirement gates moves only), and it enters the |
| - healthy history on its next restart. |
| -7. Next console roll: through dev, with the proof carried. |
| +1. **Fix the bindings.** On dev's and flagship's console boxes, check that the |
| + one `Unit`-stamped route is the host `ExecStart` names, and restamp where |
| + it isn't (2026-08-23 design §4c). Flagship was stamped on |
| + `console.carloku.com` while `ExecStart` names |
| + `live/platform.carlosframework.com`; confirm the current state on the box. |
| +2. Ship D1, D2 and D3 in one platform release. With no config, D3 is inert. |
| +3. Set the health probe on the console routes on dev and flagship. |
| +4. Roll the release's boxes (`carlos system update`) on dev and flagship. |
| +5. **On dev:** run `carlos proof keygen` on the console box, set the four |
| + `PROVE` variables, and roll the console to dev with the new recipe. That |
| + roll is the first live check of D1: watch the journal for the settle line. |
| +6. **Rehearse on dev** (codex 9): |
| + - Ship the public `carlos-linux-arm64` as a console release using a |
| + **v0.24.1 or older client**, which has no ship-side check and writes no |
| + `program`. Promote it and confirm that the box's D2 check refuses it, |
| + with no restart. |
| + - Ship a console built to exit 2 on start, from a real `./cmd/console` so |
| + D2 passes. Confirm D1 rolls back within 30 s, writes a refusal, raises |
| + the alert, and retries after a re-promote. |
| + - Ship a console that runs but never answers `/healthz`. Confirm the |
| + deadline rollback replaces the running process. |
| + - Delete all three releases. |
| +7. **Wait for dev's proof** of the build rolled in step 5. |
| +8. **Roll that build to flagship.** The recipe carries the proof (codex 8). |
| + If the flagship console must move before dev can mint, use `--bootstrap` |
| + once. |
| +9. **On flagship:** set `CARLOS_REQUIRE_PROOF` with dev's public key. The |
| + live build is unaffected, because the requirement gates moves only, and it |
| + enters the healthy history 15 minutes later (§4.5). |
| +10. Every later console roll goes through dev, with the proof carried. |
| host.env on the flagship box is not delivered by tofu `user_data` for this; |
| whole-root applies restart every box (memory: carloku-infra-split). Set the |
| @@ -404,24 +506,34 @@ checks whether host.env has a managed home it should go in instead. |
| ## 7. Testing |
| - **D1:** `makeRestartRoute`'s named-unit arm with a fake `systemctl show` |
| - sequence: healthy after N polls; `failed`; NRestarts moved; MainPID changed |
| - between active polls; deadline; not running before (no check). The start |
| - callback calls `reset-failed` before `start`. `adoptHost` with a failing |
| - settle: repoints back, starts, writes the refusal record; the next pass, |
| - agent or root, skips without relaying; a pointer with a new `PromotedAt` |
| - clears it. |
| + sequence: healthy after N polls; `failed`; `InvocationID` changed after |
| + the new invocation was fixed; `NRestarts` reset to 0 by the manual restart |
| + and the unit healthy (must pass); deadline with the process still running; |
| + not running before (no check); `ExecStart` naming another host's link (no |
| + check, plain try-restart). A D1 rollback runs `reset-failed` then |
| + `restart`, and replaces a still-running rejected process. Other start |
| + callers keep `start`. `adoptHost` with a failing settle: repoints back the |
| + bound host and every earlier host whose link names the rejected version, |
| + writes the refusal record; the next pass, agent or root, skips without |
| + relaying; a pointer with a new `PromotedAt` clears it. |
| - **D2:** `buildinfo` read from tiny stripped binaries with two different |
| main packages, cross-built for linux-arm64 by a test helper at test time |
| (no binaries checked in). Ship refuses, accepts with |
| `--program-change`, accepts non-Go, accepts a first release. The API door |
| and the CLI call the same function. `adoptHost` refuses a mismatched staged |
| - binary before `PointLive` and writes a refusal. |
| + binary before `PointLive` and writes a refusal. It accepts a mismatch whose |
| + staged sha256 is in the healthy history (rollback across a deliberate |
| + change). |
| - **D3:** proof canonical encoding round-trip and signature; mint conditions |
| - each false in turn; `adoptHost` with the requirement set: no proof, bad |
| - signature, wrong sha256, wrong app, wrong prover, valid proof, healthy |
| - history. No requirement set: no bucket GET is made (assert on the fake |
| - store). |
| -- **The dev rehearsal in §6.5** is the end-to-end test. Mocks cannot show |
| + each false in turn, including a live link and a running exe that hash |
| + differently; `adoptHost` with the requirement set: no proof, bad signature, |
| + wrong sha256, wrong app, wrong prover, valid proof, healthy history; a |
| + valid proof for the manifest's sha256 with a staged cache hit whose bytes |
| + hash differently (refused at the binding check). The policy is read from a |
| + host.env file that changes between passes, and the agent sees the change |
| + without a restart. The agent re-requests a mint until the proof appears. No |
| + requirement set: no bucket GET is made (assert on the fake store). |
| +- **The dev rehearsal in §6 step 6** is the end-to-end test. Mocks cannot show |
| that systemd's start-limit state and `reset-failed` behave as written. |
| ## 8. Not in this spec |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-console-release-defences.md b/docs/superpowers/specs/CODEX-FINDINGS-console-release-defences.md| new file mode 100644 |
| index 000000000..3dd0c06ee |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-console-release-defences.md |
| @@ -0,0 +1,19 @@ |
| +1. **P1 — Rollback can leave the rejected process running.** [§2.2](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/docs/superpowers/specs/2026-10-06-console-release-defences-design.md:114) adds failures where the unit remains active—for example, readiness never succeeds. The existing rollback restores the symlink and invokes `start` ([adopt.go:1194](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/internal/release/adopt.go:1194)); `systemctl start` explicitly does nothing to an already-running unit ([agent.go:3208](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/internal/host/agent.go:3208)). Consequently, the rejected binary keeps running, the rollback settle check fails, and the refusal prevents recovery attempts. Require rollback to replace a still-running rejected process before checking the restored build. |
| + |
| +2. **P1 — Shared-unit adoption can reject a healthy console indefinitely.** [§2.2](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/docs/superpowers/specs/2026-10-06-console-release-defences-design.md:106) assumes the first host’s restart runs the new build. Adoption processes hosts alphabetically ([registry.go:800](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/internal/registry/registry.go:800)), moves only that host’s link, then restarts ([adopt.go:1045](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/internal/release/adopt.go:1045), [adopt.go:1101](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/internal/release/adopt.go:1101)). If `ExecStart` uses a later host’s link, the restart runs the old build; D1 rejects it and poisons the later host before its link moves. This exact binding problem is already documented in [the console design §4c](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/docs/superpowers/specs/2026-08-23-console-as-an-app-design.md:305). Require either verified `Unit`/`ExecStart` binding or adoption and rollback coordinated across the shared unit before removing the script’s final restart. |
| + |
| +3. **P1 — D3 can certify bytes that never ran.** [§4.3](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/docs/superpowers/specs/2026-10-06-console-release-defences-design.md:268) takes SHA256 from the staged manifest and checks only the executable’s directory. Version-name reuse after pruning is supported ([release.go:632](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/internal/release/release.go:632)). An older binary can remain staged without its manifest; backfill then fetches the reused version’s **new** manifest without checking the existing binary ([adopt.go:1402](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/internal/release/adopt.go:1402)). The mint conditions can therefore sign proof for new bytes while the old bytes are running. Existing staged binaries also bypass download verification ([adopt.go:1317](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/internal/release/adopt.go:1317)). Require verified agreement between the proof digest and the actual running/staged artifact, including cache hits and manifest backfills. |
| + |
| +4. **P2 — The pre-restart `NRestarts` baseline rejects healthy restarts.** [§2.2](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/docs/superpowers/specs/2026-10-06-console-release-defences-design.md:88) requires the counter to remain unchanged across `try-restart`. Systemd resets this counter on a manual stop/start; see `service_enter_dead`, `service_start`, and `service_stop` in [systemd’s implementation](https://github.com/systemd/systemd/blob/v255/src/core/service.c). A healthy unit with a previous automatic restart can move from `NRestarts=1` to `0`, which the proposed rule declares failed. Conversely, a new automatic restart could return it to the old baseline. Track restarts within the new invocation rather than comparing against the previous invocation’s counter. |
| + |
| +5. **P2 — “At most once per build” can lose the proof trigger.** [§4.3](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/docs/superpowers/specs/2026-10-06-console-release-defences-design.md:278) promises minting shortly after the bake period through a single relay request. The existing relay explicitly loses changes made while `carlos-stage.service` is running ([agent.go:1207](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/internal/host/agent.go:1207)). If that pass does not mint the proof, or its proof write fails, the agent never asks again for that build; minting falls back to the hourly pass. Require bounded retries until proof publication is observed, or an acknowledged pending request. |
| + |
| +6. **P2 — Editing host.env does not update the running agent’s proof policy.** [§4.5’s break-glass procedure](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/docs/superpowers/specs/2026-10-06-console-release-defences-design.md:332) and [§6’s rollout](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/docs/superpowers/specs/2026-10-06-console-release-defences-design.md:387) only change the file. The agent receives it through `EnvironmentFile` at process start ([carlos-edge.service:18](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/internal/host/units/carlos-edge.service:18)); newly launched root passes receive the new values ([carlos-stage.service:30](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/internal/host/units/carlos-stage.service:30)). After removal, the existing agent can continue rejecting unproven builds before requesting root, delaying break glass until the hourly pass. After enabling proving, it will not know to request minting. Specify how configuration changes reach both processes and how break glass triggers immediate adoption. |
| + |
| +7. **P2 — An intentional program change prevents rollback to the previous program.** [§3.2](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/docs/superpowers/specs/2026-10-06-console-release-defences-design.md:213) checks `program_change` only on the incoming manifest. After an authorized A→B main-package change, promoting the old A release back fails because A’s immutable manifest lacks that flag. D3’s healthy-history exception in [§4.5](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/docs/superpowers/specs/2026-10-06-console-release-defences-design.md:313) does not bypass D2. Define the rollback behavior for previously accepted program identities so recovery does not require shipping another release. |
| + |
| +8. **P2 — The rollout requires a proof before configuring its producer.** [§6 step 3](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/docs/superpowers/specs/2026-10-06-console-release-defences-design.md:384) rolls the console using the new recipe, but that recipe refuses flagship shipping without a dev proof ([§5.2](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/docs/superpowers/specs/2026-10-06-console-release-defences-design.md:366)). Dev’s proving key and configuration are not installed until step 4. Reorder bootstrap so the first required proof exists before invoking the flagship recipe. |
| + |
| +9. **P2 — The prescribed D2 rehearsal explicitly disables D2.** [§6 step 5](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/docs/superpowers/specs/2026-10-06-console-release-defences-design.md:389) ships the CLI with `--program-change` and expects adoption to refuse it. [§3.1](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/docs/superpowers/specs/2026-10-06-console-release-defences-design.md:198) records that override, and §3.2 explicitly honors it. Following the rehearsal therefore permits the wrong binary to reach restart and tests D1 instead. Exercise adoption’s D2 refusal with a mismatched artifact whose manifest has no override. |
| + |
| +Verdict: reject. |
| \ No newline at end of file |