carlos / platform Public

spec: console release defences, rev 1

After the 2026-10-06 console outage (v0.24.2's console artifact was the
carlos CLI): D1 named-unit settle check and rollback with a refusal record,
D2 refuse a different Go main package at ship and at adoption, D3 opt-in
dev-proof requirement for the flagship console, D4 public console artifact
and a dev-first roll recipe. Plus the Astra review brief.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev d7293df0cb4bacd2f935078b585e41d20ba874e4 parent 67547f5
2 files changed, +494 −0
  • docs/superpowers/specs/2026-10-06-console-release-defences-design.md +436 −0
  • docs/superpowers/specs/CODEX-REVIEW-BRIEF-console-release-defences.md +58 −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
new file mode 100644
index 000000000..dc8200c55
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-06-console-release-defences-design.md
@@ -0,0 +1,436 @@
+# Console release defences
+
+Status: draft rev 1, 2026-10-06. Branch `design-console-release-defences`.
+
+## 0. Why
+
+On 2026-10-06 the flagship console (console.carloku.com, dashboard.carloku.com,
+platform.carlosframework.com) answered 502 for 24 minutes, 09:30 to 09:54 UTC.
+
+What happened, confirmed from the box journal and the bucket:
+
+- At 09:30:15 a console release named `v0.24.2` was shipped to
+ `apps/bab/console/releases/v0.24.2/` and promoted to `canary/console` at
+ 09:30:30. Its one artifact was the public `carlos-linux-arm64` from the
+ v0.24.2 GitHub release (sha256 `bc53cfc5…`, matching that release's
+ `SHA256SUMS.txt`): the CLI, not `./cmd/console`. The manifest says
+ `built_by: carlos v0.24.2`. It was a hand step, not `roll-console.sh`, which
+ builds `./cmd/console` and names versions by git sha.
+- The README's release recipe never mentions the console, and the public
+ release carries no console artifact, so whoever rolled v0.24.2 had nothing
+ correct to ship.
+- The root stage pass staged it, repointed all three live links, and ran
+ `systemctl try-restart console.service`. `console serve` is not a `carlos`
+ verb, so the new process printed the CLI's usage and exited 2 after 55 ms.
+ `try-restart` had already returned 0 (the unit is `Type=simple`), so
+ `adoptHost`'s existing rollback branch (`internal/release/adopt.go`, the
+ `rerr != nil` arm: poison, repoint back, start) never ran.
+- systemd restarted the unit five times in one second, hit its start limit and
+ left it `failed`. The live links named the bad build; nothing looked again.
+- Nothing alerted. A person noticed.
+
+Recovery was a promote of the previous build `f4478b15` back to
+`canary/console`, then `systemctl reset-failed` and `start` over SSM: the
+repoint alone does not revive a unit systemd has given up on.
+
+## 1. Rulings
+
+These were decided with Paul on 2026-10-06 and are not reopened here.
+
+1. **Four defences**, in this order of value: (D1) a box rolls back a named
+ unit that will not come up; (D2) `carlos ship` and adoption refuse a binary
+ whose Go main package differs from the app's previous release; (D3) the
+ flagship console adopts only builds proven on dev; (D4) process: the release
+ publishes a console artifact and the recipe rolls it dev first.
+2. **No new AWS account.** Dev (account 640012953448,
+ platform.dev.carlosframework.com) already is the separate test deployment.
+ The failure was that it was skipped. D3 makes skipping it impossible for the
+ flagship console without root on the console box.
+3. **D1 and D2 are on for every deployment** and add no steps. **D3 is off
+ unless a deployment turns it on.** An operator who does not want the
+ testing ceremony (Tito, a self-hoster) configures nothing and adopts
+ exactly as before, still covered by D1 and D2. There is no per-promote
+ bypass flag: the bypass is local and explicit, as for bake windows
+ (`internal/release/bake.go`).
+4. **Enforcement lives on the box that adopts**, not in the `carlos` client.
+ Anyone holding a deployment's release key can promote anything, and
+ today's promote was a hand step, so a client-side check would not have
+ fired.
+5. **Uptime alerting from outside the box** (would have paged within a
+ minute) belongs to #192, not this spec.
+6. **Scope is named-unit routes** (`registry.Route.Unit`, today only
+ `console.service`) for D1 and D3. Box binaries keep `carlos system update`'s
+ own gate and rollback. Exec children and `carlos-app@` tenants are out of
+ scope for D1; D2 applies to every binary app.
+
+**Threat model.** These are defences against mistakes. A compromised release
+key or bucket is the signing chain's and the bake window's problem. Where a
+mechanism below trusts an unsigned manifest field, that is why.
+
+## 2. D1: roll back a named unit that will not come up
+
+### 2.1 The gap
+
+`makeRestartRoute`'s first arm (`internal/host/agent.go`) returns
+`systemctlTryRestartUnit(rt.Unit)`, whose error is only systemctl's. For a
+`Type=simple` unit that is "the start job was queued", which says nothing about
+whether the new binary runs. `adoptHost` already does the right thing when the
+restart callback returns an error: it poisons the version for the pass,
+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
+
+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
+ `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".
+
+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.
+
+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
+
+In order:
+
+1. The route's own health probe (`registry.Route.Health`, the probe-config
+ JSON instances already use), sent to `rt.Addr`.
+2. Else, for a route with an `Addr`: a TCP connect to it succeeds.
+3. Else: the unit stays `active` with an unchanged MainPID for 5 s.
+
+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
+
+`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
+bad build and bounce the console each time.
+
+When D1 rolls a route back, the root pass writes a **refusal record** to the
+box's bake directory (`BakeDir`, under the data dir, which the sandboxed agent
+can read):
+
+```json
+{"schema":1,"host":"console.carloku.com","app":"console","version":"v0.24.2",
+ "sha256":"bc53cfc5…","platform":"linux-arm64",
+ "pointer_promoted_at":"2026-10-06T09:30:30Z","refused_at":"…","reason":"…"}
+```
+
+`adoptHost`, on every pass, root or agent, before `ensureStaged` and before
+`stageBlocked` can relay: if the channel pointer names this sha256 for this
+platform **and** the pointer's `PromotedAt` equals the record's, print
+`refused: <version> did not come up on this box at <time> (<reason>); promote
+again to retry` and return. A pointer that moves to different bytes, or a
+**re-promote of the same version** (a new `PromotedAt`), clears it. Re-promoting
+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
+
+- A rollback writes a line naming both versions, the reason and the journal
+ tail to the root pass's output (journal).
+- The agent raises an alert through its existing sinks (`alertsink.go`: SNS,
+ which works on flagship, and `CARLOS_ALERT_WEBHOOK`) the first time it reads
+ a new refusal record: subject `console.carloku.com: v0.24.2 did not start,
+ rolled back to f4478b15`. "Rollback did not come up either" alerts with a
+ louder subject. The plan decides whether this joins the metrics `Event`
+ shape or a small sibling; it must not wait on #192.
+- `carlos status --live` shows a refused host with the refusal's version and
+ reason.
+
+## 3. D2: refuse the wrong program
+
+### 3.1 At ship
+
+For a `binary` artifact, `carlos ship` reads the Go build info with
+`debug/buildinfo.ReadFile`. This works on a stripped (`-s -w`) binary built for
+another architecture without running it: a stripped arm64 `carlos` read on
+amd64 gives `path github.com/carlosframework/platform`, and a console build
+gives `github.com/carlosframework/platform/cmd/console`.
+
+- The manifest gains `program` (string, the build info's main package path),
+ additive to schema 1 like `capabilities` and `label`: omitted when empty,
+ ignored by old binaries. A non-Go binary has no build info and no `program`.
+ With several platform artifacts, every one must report the same path, or
+ ship refuses.
+- Ship reads the app's **latest** release's manifest. If both name a
+ `program` and they differ, ship refuses:
+
+ ```
+ this artifact is github.com/carlosframework/platform; console's releases
+ are github.com/carlosframework/platform/cmd/console. If the app really is
+ changing program, ship again with --program-change.
+ ```
+
+- `--program-change` ships anyway and records `"program_change": true` in the
+ manifest.
+- No previous release, a previous release without `program`, or a non-Go
+ artifact: ship as today. The check starts with the next release of each app.
+- The console's API ship door (`internal/console/apicli.go`, the
+ `release.Ship` caller) makes the same check server-side, so API-mode ships
+ cannot skip it and direct-bucket ships (`cli.go`) do it client-side. Both
+ call one function in `internal/release`.
+
+"Latest" is the newest release by manifest `built_at` under
+`apps/<acct>/<app>/releases/`. The plan may use the channel heads instead if
+listing every release is too slow; either catches today's case.
+
+### 3.2 At adoption
+
+`program` is an unsigned manifest field, and an older client writes none. So
+the box checks the bytes themselves. In `adoptHost`, after `ensureStaged` and
+before the capability converge and `PointLive`: read build info from the
+staged new binary and from the binary `cur` names. If both are readable and
+their main package paths differ, and the new manifest does not carry
+`program_change: true`, refuse:
+
+```
+console.carloku.com: v0.24.2 is github.com/carlosframework/platform, the live
+build is …/cmd/console — not pointed live (ship with --program-change if this
+is intended)
+```
+
+and write a refusal record (§2.4) 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.
+
+## 4. D3: the flagship console adopts only builds proven on dev
+
+### 4.1 Shape
+
+- A **proving deployment** (dev) mints a signed **proof** once a build has
+ run healthily on its console for a bake period.
+- An operator **carries** the proof to the target deployment's bucket. It is
+ signed, so carrying needs no trust and no access between the AWS accounts.
+- A **requiring deployment** (flagship) adopts a build of a listed app only
+ with a proof for those exact bytes from a prover it trusts, or a build it
+ has itself run healthily before.
+
+### 4.2 The proof
+
+```json
+{"schema":1,"app":"console","platform":"linux-arm64",
+ "sha256":"e47fcc1e…","version":"f4478b15",
+ "prover":"dev","active_since":"…","proven_at":"…",
+ "sig":"<base64 ed25519 over the canonical JSON of every other field>"}
+```
+
+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.
+
+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.
+
+### 4.3 Minting on dev
+
+Dev's console box host.env gains `CARLOS_PROVE_APPS=console`,
+`CARLOS_PROVER_NAME=dev`, `CARLOS_PROVING_KEY_FILE=/etc/carlos/proving.key`
+(root 0600) and `CARLOS_PROVE_AFTER=15m`.
+
+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;
+- no refusal record names S;
+- no proof for S exists yet in dev's bucket.
+
+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.
+
+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.
+
+### 4.4 Carrying it
+
+- `carlos proof get --app console --sha256 <S>`, against the proving
+ deployment (dev env), prints the proof JSON, or fails "not proven yet:
+ <S> has been live on dev since <t>, proven after <15m>".
+- `carlos proof put --app console <file>`, against the target (flagship env),
+ checks the JSON's shape and writes it to
+ `apps/<acct>/<app>/proofs/<sha256>/<prover>.json`. It cannot check the
+ signature without the target's trust config, and need not: the box does.
+
+`roll-console.sh` runs both (§5.2).
+
+### 4.5 Requiring on flagship
+
+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.
+
+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
+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.
+
+Otherwise it prints `console.carloku.com: v… (sha256 …) is not proven on dev;
+staying on <cur>` and returns. This is not a refusal record: the box looks
+again, so the build adopts soon after its proof arrives. The agent's fast
+loop runs every 2 s, so a missing proof is cached per (route, sha256) for
+30 s: at most two bucket GETs a minute per listed route while waiting, none
+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.
+
+### 4.6 Who sees none of this
+
+A deployment with neither variable set: no proof is minted, read or required,
+and adoption is unchanged except for D1 and D2. Tito, self-hosters and every
+customer app on carloku.com are in this position.
+
+## 5. D4: process
+
+### 5.1 The release carries a console
+
+The release recipe (README "Releasing the platform") builds
+`console-linux-arm64` and `console-linux-amd64` from the tag with
+`go build -trimpath -ldflags "-s -w -X …version.Build=vX.Y.Z" ./cmd/console`,
+lists them in `SHA256SUMS.txt` (signed as today), and publishes them with the
+other files. Step 4's "eight files" becomes ten.
+
+### 5.2 The console roll recipe
+
+`carloku-infrastructure/scripts/roll-console.sh` changes:
+
+- **A `dev` target** with the dev coordinates (profile `kass`, bucket
+ `carlos-dev-640012953448`, console box `i-05498eada6f0afd23`,
+ platform.dev.carlosframework.com). The dev coordinates move here from
+ platform-infrastructure, because flagship's rule now depends on dev.
+- **`--artifact <file>`**: roll exactly this file instead of building. A
+ release's console roll uses the public `console-linux-arm64`, verified
+ against the signed `SHA256SUMS.txt`, so dev and flagship run the bytes the
+ public got. Without `--artifact` it builds once with `-trimpath` and keeps
+ the file for the next target.
+- **`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 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
+the public `carlos-linux-*` binary is never a console artifact. D2 enforces
+that now, but the recipe should not lean on it.
+
+## 6. Rollout
+
+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.
+
+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
+line over SSM and record it in carloku-infrastructure's runbook. The plan
+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.
+- **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.
+- **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
+ that systemd's start-limit state and `reset-failed` behave as written.
+
+## 8. Not in this spec
+
+- External uptime alerting: #192.
+- D1 for exec children and `carlos-app@` units. Those restarts already have
+ other failure handling, and a wrong-program ship to them is D2's job.
+- Making `carlos system update` require dev proofs for box binaries. Its own
+ gate covers box binaries, and the proof shape extends to it if wanted later.
+- Deleting the bad `apps/bab/console/releases/v0.24.2` release. That is an
+ operator clean-up, independent of this work; D2's box check makes it inert
+ once rolled.
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-console-release-defences.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-console-release-defences.md
new file mode 100644
index 000000000..5e6f586bd
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-console-release-defences.md
@@ -0,0 +1,58 @@
+# Review brief: console release defences
+
+You are reviewing `docs/superpowers/specs/2026-10-06-console-release-defences-design.md`
+on branch `design-console-release-defences` in the carlos/platform repo, at the
+commit named in the prompt. Read the spec, then read the code it cites:
+
+- `internal/release/adopt.go`, `adoptHost`: the restart-failure rollback
+ branch, `poisoned`, `ensureStaged`, `PointLive`, `stageBlocked`, `BakeDir`,
+ `WithStartUnit`.
+- `internal/host/agent.go`: `makeRestartRoute`, `rootRestartRoute`,
+ `systemctlTryRestartUnit`, `makeStartRoute`, the `-stage-only` verb and the
+ stage-request relay, the fast converge loop.
+- `internal/release/release.go`: `Manifest`, `Ship`.
+- `cli.go` (the direct-bucket ship) and `internal/console/apicli.go` (the API
+ ship door).
+- `internal/release/bake.go`, `internal/release/signer.go`,
+ `internal/registry/registry.go` (`Route.Unit`, `Route.Health`, `Route.Addr`).
+- `internal/host/alertsink.go`.
+
+## Fixed rulings (§1): do not re-argue them
+
+Four defences (D1 rollback on a named unit that will not come up, D2 refuse
+the wrong Go main package, D3 dev-proof requirement for the flagship console,
+D4 process). No new AWS account. D1 and D2 on everywhere; D3 opt-in per
+deployment with no per-promote bypass. Enforcement on the adopting box, not
+the client. External uptime alerting is out of scope. The threat model is
+mistakes, not a compromised key or bucket.
+
+## What to look for
+
+Report mechanisms that fail to honour those rulings or that break something
+that works today. In particular:
+
+1. Interleavings between the agent's fast loop, the root stage pass, the
+ hourly root pass and the stage-request relay that defeat the refusal
+ record, the proof wait or the settle check. Examples: a relay storm, a
+ bounce loop, a refusal the agent cannot read, a proof minted for a build
+ that is no longer live.
+2. States in which D1 makes things worse than today, such as rolling back a
+ healthy build, leaving the unit stopped, or fighting an operator's
+ deliberate stop.
+3. Ways today's exact failure still gets through: CLI binary as console,
+ hand ship, promote with the root key.
+4. Ways D3 blocks a recovery it should allow (rollback to a previously-run
+ build, break glass) or allows a build it should block (different bytes
+ under a proven version name).
+5. Claims about the code that are wrong. Cite file:line.
+6. Placeholders, contradictions, or requirements an implementer could read
+ two ways.
+
+A mechanism that is named, bounded and assigned to the plan is complete at
+spec level; do not pull plan-level detail up into the spec.
+
+## Output
+
+Number each finding, give it a severity (P1 breaks a ruling or production,
+P2 a real defect, P3 clarity), and cite the spec section and any code
+file:line. End with a one-line verdict: accept, accept with fixes, or reject.