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>
2 files changed,
+494
−0
docs/superpowers/specs/2026-10-06-console-release-defences-design.md+436 −0docs/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. |