spec: console release defences rev 4, Astra round 3 answered
Round 3 (committed unedited): two of the six re-checked findings needed more, plus one new finding; all accepted. The rollback group keys on a non-empty upstream, Socket before Addr. Every group route whose link names the rejected build is stamped to what the shared process now runs, including routes advanced by an earlier pass. A refused build is retried with a new `carlos promote --retry`, because a plain re-promote of the same version is a no-op.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+63
−21
docs/superpowers/specs/2026-10-06-console-release-defences-design.md+49 −21docs/superpowers/specs/CODEX-FINDINGS-console-release-defences-r3.md+14 −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 e98eb06df..295f05b44 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,11 @@ |
| # Console release defences |
| -Status: draft rev 3, 2026-10-06. Branch `design-console-release-defences`. |
| +Status: draft rev 4, 2026-10-06. Rev 4 answers Astra round 3 (`…-r3.md`, |
| +reject). Of the six round-2 findings it re-checked, four were fixed. Two |
| +needed more: findings 10 and 11, now in §2.3, where groups key on a non-empty |
| +`Socket` or `Addr` and every group route is stamped to the bound route's |
| +restored version. New finding 13, a `promote --retry`, is in §2.5. All |
| +accepted. Branch `design-console-release-defences`. |
| Rev 2 answered 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, |
| @@ -167,16 +172,23 @@ may already have repointed their links to the rejected version and had their |
| registry versions stamped (`SetVersions`, `internal/release/adopt.go:1203`), |
| and the edge's `X-Carlos-Version` comes from that stamp |
| (`internal/edge/edge.go:752`). The rollback covers exactly the routes that |
| -share the rejected **process**: the bound route plus every unstamped route of |
| -the same app whose `Addr` equals the bound route's `Addr`. A route of the same |
| -app with a different `Addr` is a different process, and the rollback does not |
| -touch it, whatever version it runs. |
| - |
| -For each route in the group that this pass moved, the pass remembers the |
| -version it moved from. On rollback each one is repointed to **its own** |
| -previous version and its registry versions are restored with `SetVersions`. |
| -The refusal record (§2.5) is written for **every host in the group**, so no |
| -sibling re-adopts the rejected build on the next pass. |
| +share the rejected **process**. A route's upstream is its `Socket` if set, |
| +else its `Addr` (`Socket` wins, `internal/registry/registry.go:40`). The group |
| +is the bound route plus every unstamped route of the same app with the **same |
| +non-empty upstream**. A bound route with no upstream at all is a group of one, |
| +and two empty upstreams never match (codex 10, round 3). A route of the same |
| +app with a different upstream is a different process, and the rollback does |
| +not touch it, whatever version it runs. |
| + |
| +Every route in the group is served by the one process, so after a rollback |
| +each of them is serving whatever the bound route rolled back to. On rollback, |
| +every group route whose live link names the rejected version is repointed to |
| +the **bound route's restored version**, and its registry versions are set to |
| +match with `SetVersions`. This applies whether the route moved in this pass |
| +or in an earlier, interrupted one (codex 11, round 3). The stamp then names |
| +what is actually answering. The refusal record (§2.5) is written for **every |
| +host in the group**, so no sibling re-adopts the rejected build on the next |
| +pass. |
| ### 2.4 Readiness |
| @@ -210,10 +222,20 @@ can read): |
| `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. |
| +`refused: <version> did not come up on this box at <time> (<reason>); retry |
| +with: carlos promote --retry …` and return. A pointer that moves to different |
| +bytes clears it, and so does a pointer re-signed with a new `PromotedAt`. |
| + |
| +**The retry needs a new flag (codex 13, round 3).** Promoting the version a |
| +channel already names is a successful no-op that does not rewrite the pointer |
| +(`PromoteOnLadder`, `internal/release/release.go:910`), so a plain re-promote |
| +leaves `PromotedAt` unchanged and the refusal standing. |
| +`carlos promote --retry <version> <channel>` re-signs the pointer with a fresh |
| +`PromotedAt` even when it already names `<version>`. It refuses unless it does |
| +already name `<version>`, so it can only renew a promotion, never change one. |
| +The API promote door takes the same `retry` field. Both reach it through one |
| +`PromoteOnLadder` option, so the ladder, scope and authorization checks run |
| +exactly as for any promote. |
| The record keys on the artifact sha256 read from the manifest, not on the |
| version name, because names get reused. |
| @@ -527,7 +549,8 @@ Order matters: the box binary is what checks, so it goes first. |
| 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. |
| + the alert, stays refused after a plain re-promote, and retries after |
| + `carlos promote --retry`. |
| - Ship a console that runs but never answers `/healthz`. Confirm the |
| deadline rollback replaces the running process. |
| - Delete all three releases. |
| @@ -558,11 +581,16 @@ they have a managed home they should go in instead. |
| `restart`, and replaces a still-running rejected process. Other start |
| callers keep `start`. A binding mismatch whose `try-restart` fails still |
| returns the error and rolls back. `adoptHost` with a failing settle: |
| - repoints back the bound host and every earlier same-`Addr` route of the |
| - app, each to its own previous version with its registry versions restored; |
| - leaves a same-app route on a different `Addr` alone; writes the refusal |
| - record for every host in the group; the next pass, agent or root, skips without |
| - relaying; a pointer with a new `PromotedAt` clears it. |
| + every group route whose link names the rejected version is repointed and |
| + stamped to the bound route's restored version, including one advanced by |
| + an earlier interrupted pass; groups key on `Socket` before `Addr`; two |
| + routes with empty upstreams are never grouped; a same-app route on a |
| + different upstream is left alone; the refusal record is written for every |
| + host in the group; the next pass, agent or root, skips without relaying. |
| +- **Retry:** a plain re-promote of the refused version is still a no-op and |
| + the refusal stands; `promote --retry` through the CLI (direct mode) and the |
| + API door re-signs with a fresh `PromotedAt` and clears it; `--retry` against |
| + a channel naming a different version is refused. |
| - **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 |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-console-release-defences-r3.md b/docs/superpowers/specs/CODEX-FINDINGS-console-release-defences-r3.md| new file mode 100644 |
| index 000000000..006facedc |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-console-release-defences-r3.md |
| @@ -0,0 +1,14 @@ |
| +| Round 2 finding | Rev 3 sufficient? | Why | |
| +|---|---|---| |
| +| 6. Agent cannot read updated proof policy | Yes | §4.5 specifies a separate root-owned `0644` file, reread by both processes each pass. | |
| +| 7. D2 prevents rollback across program changes | Yes | §3.2 now records and seeds program history for every route kind. | |
| +| 9. Rehearsal encounters ship-side D2 | Yes | §6 explicitly selects an older client in direct-bucket mode. | |
| +| 10. Rollback affects unrelated processes | No | [§2.3](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/docs/superpowers/specs/2026-10-06-console-release-defences-design.md:170) still groups equal empty `Addr` values, including unrelated routes with distinct [Unix sockets](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/internal/registry/registry.go:40). | |
| +| 11. Shared-host stamps and refusal coverage | No | [§2.3](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/docs/superpowers/specs/2026-10-06-console-release-defences-design.md:175) restores only routes moved **this pass**; a sibling advanced by an interrupted earlier pass remains incorrectly stamped when the bound host subsequently fails. | |
| +| 12. Binding fallback suppresses restart errors | Yes | §2.2 explicitly preserves the command’s error and existing rollback behavior. | |
| + |
| +New finding at `aa05924e`: |
| + |
| +13. **P2 — Re-promoting the rejected version does not trigger the promised retry.** [§2.5](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/docs/superpowers/specs/2026-10-06-console-release-defences-design.md:210) says re-promoting the same version changes `PromotedAt`, clears the refusal, and “needs nothing new.” However, [`PromoteOnLadder`](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/internal/release/release.go:910) returns a successful no-op when the channel already names that version, without rewriting the pointer. Both the [direct CLI](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/cli.go:2417) and [console API](/home/paulca/amadan.net/carlos/platform-design-console-release-defences/internal/console/apicli.go:899) use this path. D1 rolls back local state while leaving the channel on the rejected version, so following the refusal’s retry instruction leaves its timestamp unchanged and adoption blocked. Specify an explicit retry operation that renews the promotion identity through both interfaces, and test it against the existing no-op behavior. |
| + |
| +Verdict: reject. |
| \ No newline at end of file |