spec: instance update settings rev 3 — answers Astra S1 (root judges eligibility, signed promotion records, install requests, held_build, retention)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+172
−49
docs/superpowers/specs/2026-10-07-instance-update-settings-design.md+122 −49docs/superpowers/specs/CODEX-FINDINGS-SETTINGS-S1.md+50 −0
CI passed — run details
diff --git a/docs/superpowers/specs/2026-10-07-instance-update-settings-design.md b/docs/superpowers/specs/2026-10-07-instance-update-settings-design.md| index f6266799d..c9e4042dc 100644 |
| --- a/docs/superpowers/specs/2026-10-07-instance-update-settings-design.md |
| +++ b/docs/superpowers/specs/2026-10-07-instance-update-settings-design.md |
| @@ -1,6 +1,6 @@ |
| # Instance update settings: auto-update, channel, major hold, pin |
| -Status: draft rev 2, 2026-10-07. Split out of the zero-downtime deploys |
| +Status: draft rev 3, 2026-10-07. Split out of the zero-downtime deploys |
| spec (`2026-10-07-zero-downtime-deploys-design.md`) at its rev 6. Rev 1 was |
| a design with console-signed per-instance directives. Astra's round 6 |
| (`CODEX-FINDINGS-ZERO-DOWNTIME-R6.md`) rejected it with 6 findings. |
| @@ -10,7 +10,10 @@ build an instance runs is a preference, not a security boundary.** "Simple |
| should win. As long as folks can choose whether to update or not, as long |
| as the implementer has added the choice, that's good." So rev 2 drops |
| directives, and root applies the settings itself (§4). §7 maps the six |
| -findings. |
| +findings. Astra closed all six on rev 2 and raised five new ones |
| +(`CODEX-FINDINGS-SETTINGS-S1.md`). Rev 3 answers them by making **root |
| +the only judge of eligibility**, recording only facts on the record, and |
| +adding a durable signed record of every promoted build (§4.1). |
| ## 1. Why |
| @@ -94,51 +97,99 @@ pass applies them**, in the same pass, with the same signed-pointer reads, |
| pipeline bake windows (ratchet memory, `internal/release/adopt.go`), |
| `FastTrack`, staging and stamping it uses today. There is one decision |
| function, `release.Target(route, pointer, settings)`, and root is the only |
| -caller that installs. |
| - |
| -For each exec route, the first matching rule applies: |
| - |
| -1. **`pin` set:** install the pinned build. It must be a shipped build of the |
| - route's own app: its manifest exists in the release store, and staging's |
| - sha256 check passes. Bake doesn't apply, because a person chose it. |
| -2. **`updates = manual`:** don't move. The installed build stays. |
| - - **On a box with nothing installed for the route** (a move, a restore or |
| - a new box), install the record's **`frozen`** build. |
| - - The console sets `frozen` in two cases only. When `manual` is turned |
| - on, it is set to the channel head at that moment, after bake. When |
| - someone installs by hand, it is set to the channel head they |
| - installed. |
| - - It is never set from box status. |
| -3. **`major = N`:** follow the channel exactly as today, including bake. |
| - But don't move to a head whose semver tag is absent or has another major. |
| - The instance is *held at major N*. |
| +caller that installs. **The console never chooses a build.** It records |
| +what people asked for and what boxes reported. |
| + |
| +### 4.1 Which builds root may install |
| + |
| +Settings choose among builds. They never widen the set of builds that can |
| +be installed. Manifests and artifacts are unsigned |
| +(`internal/release/release.go`), so a manifest existing in the store |
| +proves nothing. **Root installs build V of app A only if it can verify |
| +that V was authorized for A:** |
| +- a signed channel pointer of A names V as its head or in its history |
| + (today's check); or |
| +- a **signed promotion record** for (account, A, V). This is new: every |
| + promotion writes `releases/<acct>/<app>/promoted/<version>`, signed with |
| + the key that signs pointers. Promotion is the act that authorizes a |
| + build, so this makes the authorization durable after V falls out of the |
| + pointer's ten-entry history. |
| + |
| +So a pin must name a build that has been **promoted** to some channel of |
| +the app. A build that was shipped but never promoted is refused, both by |
| +the console when the pin is set and by root. A build already staged and |
| +verified on the box can be recovered there without verifying again. That |
| +is the zero-downtime spec's retained identity. |
| + |
| +### 4.2 The record carries facts, not choices |
| + |
| +| Field | Written by | Meaning | |
| +|-------|------------|---------| |
| +| settings (§3.1) | people, through the console and API | the preference | |
| +| `install_request` | the console, on "Update now" | an id that only ever increases. Consumed once by root (§4.3) | |
| +| `held_build` | the console, from what boxes report | the build a held instance (`manual`, or held at major N) last had installed. Used only to restore it on a box with nothing installed | |
| + |
| +`held_build` comes from the instance's installed build in box status. That |
| +report is trusted the way settings are (§2 decision 5): at worst a forged |
| +report picks another authorized build of the same app. It is refreshed |
| +whenever a held instance's reported installed build changes, including |
| +right after an install request completes. **Turning `manual` on records |
| +the build installed now, not the channel head**, so it never moves the |
| +instance. |
| + |
| +### 4.3 The rules |
| + |
| +For each exec route, the first rule that applies decides. **The zero-downtime |
| +spec's failure records outrank all of them**: an instance never moves onto a |
| +build its box holds as failed unless it has already served that build. |
| + |
| +1. **`pin` set:** install the pinned build, if §4.1 authorizes it. Bake |
| + doesn't apply, because a person chose it. |
| +2. **A pending `install_request`** (its id is above the id root last |
| + consumed for this host): install the channel head **that this box's own |
| + bake check says is eligible**, or the current head if the request asked |
| + for `FastTrack`. `manual` and `major` are ignored for this one move. |
| + Root then records the consumed id, root-owned under |
| + `/opt/carlos/consumed/<host>`. That happens after the stamp, so an |
| + interrupted pass simply runs the request again. If the head is still |
| + baking, the request stays pending and the reason is reported. |
| +3. **`manual`, or `major = N` with a head whose semver tag is absent or has |
| + another major:** don't move. |
| + - **With nothing installed on this box** (a move, a restore or a fresh |
| + box), install `held_build`, if §4.1 authorizes it. Bake doesn't |
| + apply: the instance already ran that build. |
| + - **With no `held_build` either**, the instance is new and gets the |
| + eligible channel head. |
| 4. **Otherwise:** today's adoption, unchanged. |
| -**Install by hand** (`carlos instances update <host>`, or the app's |
| -`POST …/updates/install`) is refused on a pinned instance, naming the pin. |
| -Otherwise it sets `frozen` to the eligible channel head, ignoring `major` |
| -but keeping bake unless `FastTrack` is used. It also bumps the record, so |
| -the next pass installs it. On an `auto` instance it simply waives `major` |
| -for that one move. |
| +The console refuses "Update now" on a pinned instance, naming the pin. |
| -**The zero-downtime spec's failure records still win.** A build held by a |
| -box's failure record isn't moved onto by an instance that hasn't served it, |
| -whatever these settings say. |
| +### 4.4 Retention |
| + |
| +Bucket pruning (`internal/release/prune.go`, called from |
| +`internal/host/retention.go`) adds to its protected set, for the app, every |
| +build that a provision record names as a `pin` or a `held_build`. It also |
| +keeps the signed promotion record of every protected build. If the |
| +housekeeper can't read every record of the app, it prunes nothing for that |
| +app on that pass. That is the same refusal it makes for an incomplete |
| +protected set today. |
| + |
| +### 4.5 Rollback, named units, trust |
| **Rollback.** A box running a root binary from before this feature ignores |
| -the settings and adopts from the channel. The console shows |
| -*settings not enforced* for instances on such boxes, using the box's |
| -reported binary version, and the roll docs say so. |
| +the settings and adopts from the channel. The console shows *settings not |
| +enforced* for instances on such boxes, using the box's reported binary |
| +version, and the roll docs say so. |
| **Named-unit routes** keep today's adoption. The console refuses update |
| settings on them. |
| -**Trust, stated plainly.** Settings aren't signed. A compromised agent |
| -could change the copy on its own box. That gains it nothing, because it |
| -already runs those instances. A forged record could change an instance's |
| -settings, but a forged record can already change its channel today, so |
| -this adds no new kind of trust. Neither can install anything but a |
| -signed, shipped build of that instance's own app. |
| +**Trust, stated plainly.** |
| +- Settings, `install_request` and `held_build` aren't signed. A compromised |
| + agent, or a forged record or status report, can make an instance run a |
| + different **authorized** build of its own app. That is the accepted |
| + model (§2 decision 5). |
| +- Nothing can make root install a build that §4.1 doesn't authorize. |
| ## 5. Surfaces |
| @@ -174,24 +225,46 @@ build whatever these settings say. |
| | # | Finding | Answer | |
| |---|---------|--------| |
| -| 1 | The console signs a manual target from unauthenticated box status | Gone: no signing. `frozen` is never taken from status. It is the channel head when `manual` is turned on, or the build installed by hand (§4 rule 2) | |
| +| 1 | The console signs a manual target from unauthenticated box status | Gone: no signing. Status is trusted only as a preference among authorized builds (§4.2, §4.5) | |
| | 2 | Removing a directive restores channel adoption without a signature | Gone: no directives. Under the §2 decision 5 threat model, the settings copy is trusted like the record's channel | |
| | 3 | A directive superseded during staging | Gone: root reads the settings in the same pass as it reads the pointer, as it does for channels today. A setting changed mid-pass converges on the next pass, as a channel change does | |
| | 4 | Directed instances skip the app's own bake windows | Root applies bake with today's pipeline code for `auto` and `major` (§4 rules 3–4). Only a pin waives it | |
| -| 5 | Stale status undoes an install by hand | Gone: status is never an input (rule 2) | |
| +| 5 | Stale status undoes an install by hand | Gone: an install is a consumed request id, not a target taken from status (§4.3 rule 2) | |
| | 6 | Recomputation has no complete trigger | Gone: root evaluates the settings on every pass, so a bake expiry or a channel move is picked up exactly as today | |
| ## 8. Testing |
| -- **`release.Target`:** a table test over pin, manual (with and without |
| - an installed build, with and without `frozen`), major (matching, other |
| - and untagged heads), bake windows from the app's pipeline, `FastTrack`, |
| - and a failure-held build. |
| -- **Root pass:** a pinned build that isn't a shipped build of the route's |
| - app is refused. A manual instance on a fresh box installs `frozen`, not |
| - the head. An instance held at major N stays put while the channel moves. |
| -- **Console:** `frozen` is set when `manual` is turned on and on installs |
| - by hand, never from status. Every §3.3 refusal is tested from the |
| +- **`release.Target`:** a table test over: |
| + - pin; |
| + - a pending, consumed or interrupted `install_request`; |
| + - manual, with and without an installed build, and with and without |
| + `held_build`; |
| + - major, with a matching, other or untagged head; |
| + - bake windows from the app's pipeline, and `FastTrack`; |
| + - a failure-held build. |
| +- **Authorization:** a fresh never-promoted version with a matching |
| + manifest is refused for a pin. A promoted build that has left the |
| + pointer's history is accepted through its signed promotion record. |
| +- **Retention:** pinned and `held_build` builds survive pruning. An |
| + unreadable record set prunes nothing for the app. |
| +- **Root pass:** |
| + - a manual or major-held instance on a fresh box installs `held_build`, |
| + not the head; |
| + - an instance held at major N stays put while the channel moves; |
| + - an install request waits on this box's bake, and is consumed exactly |
| + once. |
| +- **Console:** turning on `manual` records the installed build, never the |
| + channel head. "Update now" bumps the request id. Every §3.3 refusal is tested from the |
| console, the CLI and the instance-bound credential. |
| - **Rollback:** a box whose root predates this feature shows *settings |
| not enforced*. |
| + |
| +## 9. Astra S1 findings (rev 2), answered by rev 3 |
| + |
| +| # | Finding | Answer | |
| +|---|---------|--------| |
| +| 1 | Manifest existence plus sha doesn't prove authorization | §4.1: a signed pointer, or a new signed promotion record; pins must name a promoted build | |
| +| 2 | Held instances have no recovery target on a fresh box; turning on manual swaps in the head | §4.2: `held_build` from reported installs, and turning on manual records the installed build; §4.3 rule 3 | |
| +| 3 | Retention deletes pinned or held builds | §4.4: pins and `held_build`s are protected across the app's records; an incomplete read prunes nothing | |
| +| 4 | An install request can't be told apart from turning on manual | §4.2–4.3: a separate `install_request` id, consumed once by root and re-run if interrupted | |
| +| 5 | The console can't judge bake | §4.3 rule 2: root's own bake check decides; the request waits while the build is baking; recovery installs don't bake | |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-SETTINGS-S1.md b/docs/superpowers/specs/CODEX-FINDINGS-SETTINGS-S1.md| new file mode 100644 |
| index 000000000..5c097921a |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-SETTINGS-S1.md |
| @@ -0,0 +1,50 @@ |
| +REJECT |
| + |
| +R6-1: CLOSED — No status-derived signing; `frozen` is not taken from status. |
| +R6-2: CLOSED — Changing selection among authorized builds is explicitly outside the security boundary. |
| +R6-3: CLOSED — Next-pass convergence replaces the superseded-directive guarantee. |
| +R6-4: CLOSED — Automatic and major-held adoption use the app’s pipeline and ratchet memory. |
| +R6-5: CLOSED — Stale status no longer replaces a manual target. |
| +R6-6: CLOSED — Root reevaluates automatic eligibility on each adoption pass. |
| + |
| +1. **HIGH — §4 rule 1 and “Trust”: Manifest existence and SHA256 do not establish signed release authorization.** |
| + |
| + **Code:** [internal/release/release.go:584](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/release.go:584) ships unsigned artifacts and manifests; [release.go:656](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/release.go:656) validates manifest shape and identity, without verifying a signature. [internal/release/adopt.go:1421](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/adopt.go:1421) compares bytes against that manifest’s checksum. |
| + |
| + **Failure:** A compromised box has deployment-bucket write access ([infra/modules/region-host/main.tf:166](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/infra/modules/region-host/main.tf:166)). It creates a fresh version containing arbitrary bytes and a matching manifest under the victim app, then sets a forged record’s pin to that version. Both specified checks pass, although no release signer authorized this build. Reading a valid channel pointer for another version does not authorize the pin. |
| + |
| + **Smallest fix:** Require root-verifiable release authorization binding the selected account, app and version. Verified pointer evidence can cover promoted builds; supporting arbitrary never-promoted shipped builds requires a durable signed shipping record. Settings themselves need not be signed. |
| + |
| +2. **HIGH — §4 rules 2–3: Held instances lack a reliable recovery target on a fresh box.** |
| + |
| + **Code:** [internal/provision/reconcile.go:394](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/provision/reconcile.go:394) creates routes without an installed version. [internal/release/versions.go:290](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/versions.go:290) returns no installed build when the live link is absent. |
| + |
| + **Failure:** H runs tagged major-1 build A with `major=1`; stable advances to major-2 build B. After moving or restoring H onto a fresh box, rule 3 rejects B, but no rule selects A. Every pass leaves H without an executable. |
| + |
| + Switching H to manual before moving does not preserve A either: rule 2 sets `frozen=B` from the eligible channel head while leaving A installed locally. The destination then installs B without an explicit install request. |
| + |
| + **Smallest fix:** Persist the selected recovery build for held instances and use it on moves/restores. Entering manual must preserve that selection rather than substitute the channel head. Define initial selection separately for an instance that has never installed anything. |
| + |
| +3. **HIGH — §§3.1, 4: Retention can delete a pinned or frozen recovery build.** |
| + |
| + **Code:** [internal/release/prune.go:20](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/prune.go:20) protects recent releases, channel heads/history, tags and caller-provided extras. [internal/host/retention.go:33](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/host/retention.go:33) supplies only its own box’s installed and previous versions. |
| + |
| + **Failure:** H on box X pins an untagged edge build A. After sufficient newer releases, A leaves channel history and the retention window. Box Y hosts the same app on newer builds and deletes A from the shared release store. H continues locally, but a move or restore cannot fetch its required build. Neither this spec nor the accepted zero-downtime spec’s local version-sweep protections prevents that shared-store deletion. |
| + |
| + **Smallest fix:** Make every release-pruning path protect effective pins, frozen targets and pending install targets across the app’s provision records. An incomplete record read must prevent pruning. |
| + |
| +4. **MEDIUM — §4, “Install by hand”: The ordered rules cannot distinguish an install request from enabling manual mode.** |
| + |
| + **Code:** [internal/provision/provision.go:1101](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/provision/provision.go:1101) rewrites record state; it does not create a consumable installation event. [internal/release/adopt.go:943](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/adopt.go:943) selects a target from current state. |
| + |
| + **Failure:** Manual H has A installed. An explicit update writes `frozen=B` and bumps the record. Rule 2 still says “don’t move” because A exists. An auto instance with a major hold likewise reaches rule 3, which still rejects B. Conversely, treating every changed `frozen` as an install request would install B merely when manual mode is enabled—an operation expressly required to leave A installed. |
| + |
| + **Smallest fix:** Add a distinct install-request identity and target, with precedence over manual/major holds but below pin refusal and failure holds. Record consumption so the major waiver applies once and survives interrupted passes. |
| + |
| +5. **MEDIUM — §4 rule 2 and “Install by hand”: Console-selected bake eligibility has no defined authoritative clock.** |
| + |
| + **Code:** [internal/release/bake.go:364](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/bake.go:364) derives eligibility from each box’s first observation and jitter. [internal/console/view.go:384](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/console/view.go:384) explicitly describes console eligibility as an approximation. [internal/release/adopt.go:659](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/adopt.go:659) observes current heads only. |
| + |
| + **Failure:** B looks baked from its promotion timestamp, but H’s box first observed it recently. The console cannot establish the specified “eligible channel head” using existing behavior. Trusting its estimate bypasses the actual window. Deferring to root leaves another gap: if frozen B reaches a fresh box after the channel advances to C, root never observes B; the existing bake check remains ineligible indefinitely. |
| + |
| + **Smallest fix:** Make root authoritative for requested-target bake eligibility, define observation of an authorized target that is no longer head, and distinguish a pending request from a completed frozen selection. |
| \ No newline at end of file |