carlos / platform Public

spec: instance update settings rev 4 — answers Astra S2 (digest-bound promotion records, build state beside the replica, roll ordering, request replay)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 1460e359720a533c1ec913d8f370960981d0c6f3 parent 2e8dcda
2 files changed, +143 −46
  • docs/superpowers/specs/2026-10-07-instance-update-settings-design.md +104 −46
  • docs/superpowers/specs/CODEX-FINDINGS-SETTINGS-S2.md +39 −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 c9e4042dc..486491912 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 3, 2026-10-07. Split out of the zero-downtime deploys
+Status: draft rev 4, 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.
@@ -13,7 +13,10 @@ directives, and root applies the settings itself (§4). §7 maps the six
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).
+adding a durable signed record of every promoted build (§4.1). Astra's S2
+review of rev 3 (`CODEX-FINDINGS-SETTINGS-S2.md`) closed two of those five
+and left three partial. Rev 4 answers those three and one new finding
+(§10).
## 1. Why
@@ -109,11 +112,19 @@ 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.
+- a **signed promotion record** for (account, A, V, **manifest digest**).
+ This is new: every promotion writes
+ `releases/<acct>/<app>/promoted/<version>`, signed with the key that
+ signs pointers. The digest is the sha256 of the canonical manifest bytes,
+ which carry each artifact's sha256.
+
+Promotion is the act that authorizes a build, so the record keeps the
+authorization valid after V falls out of the pointer's ten-entry history.
+Root checks that the manifest it is about to stage hashes to the record's
+digest before staging, and staging's artifact check then binds the bytes.
+A version name reused after pruning has a different manifest, so an old
+promotion record can't authorize its new bytes, even if the record is
+replayed.
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
@@ -121,21 +132,29 @@ 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
+### 4.2 Facts the platform keeps
-| 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 |
+| Fact | Where | Written by | Meaning |
+|------|-------|------------|---------|
+| settings (§3.1) | provision record | people, through the console and API | the preference |
+| `install_request` | provision record | the console, on "Update now" | an id that only ever increases |
+| **build state** | an object beside the instance's replica in the bucket | the agent, under the instance's lease | see below |
-`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.
+The **build state** object holds three things:
+- the **installed build**;
+- the **last install request root resolved**, with the build it resolved
+ to;
+- whether that request has **completed**.
+
+The agent rewrites the object each time root stamps an install or resolves
+a request (§4.3 rule 2), before reporting either as done. It is trusted
+the way settings are (§2 decision 5). A forged object can at most choose
+another authorized build of the same app.
+
+The object replaces rev 3's status-derived `held_build`. It is written in
+step with the install itself, not on the five-minute status cycle, so
+turning `manual` on can't record a build the instance has already moved
+past.
### 4.3 The rules
@@ -145,20 +164,32 @@ 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.
+2. **An `install_request` R above the last one completed** (taken from the
+ box's own record of consumed requests, or else from the build state
+ object):
+ - **If R has already been resolved to a build T,** install T. That
+ covers a pass that was interrupted, and a request picked up on
+ another box after a move.
+ - **Otherwise**, resolve R now: take the channel head that this box's
+ own bake check says is eligible, or the current head if the request
+ asked for `FastTrack`. Write {R, T} to the root-owned file
+ `/opt/carlos/consumed/<host>` before staging. The agent mirrors it
+ into the build state.
+ - **Completion:** after the stamp, mark R completed, both locally and in
+ the build state.
+ - `manual` and `major` are ignored for this one move.
+ - **If the head is still baking**, R stays unresolved 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
+ - **Nothing installed on this box** (a move, a restore or a fresh box):
+ install the build state's installed build, if §4.1 authorizes it. Bake
+ doesn't apply, because the instance already ran that build.
+ - **No build state but the instance has a replica** (it has data, so it
+ isn't new): its selection is **unknown**. Install nothing, report
+ *build unknown: use Update now or set a pin*, and leave the instance
+ down rather than guess.
+ - **Neither build state nor replica:** the instance is new and gets the
eligible channel head.
4. **Otherwise:** today's adoption, unchanged.
@@ -167,12 +198,23 @@ The console refuses "Update now" on a pinned instance, naming the pin.
### 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
+`internal/host/retention.go`) adds to its protected set, for the app:
+- every build a provision record names as a `pin`;
+- every build any build state object names, whether installed or as a
+ resolved request. 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.
+housekeeper can't read every record and build state object 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.
+
+**Order of the roll.** The housekeeper duty moves between boxes
+(`internal/host/agent.go`), and a box from before this feature protects
+only its own installed builds. So:
+- The console **refuses to enable** `pin`, `manual` or `major` on any
+ instance until every box in the deployment reports a binary that
+ protects them.
+- Before rolling any box back below that version, an operator **turns
+ retention off** for the deployment. The roll docs carry both steps.
### 4.5 Rollback, named units, trust
@@ -185,7 +227,7 @@ version, and the roll docs say so.
settings on them.
**Trust, stated plainly.**
-- Settings, `install_request` and `held_build` aren't signed. A compromised
+- Settings, `install_request` and the build state object 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).
@@ -237,24 +279,31 @@ build whatever these settings say.
- **`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`;
+ - manual, with and without an installed build, with and without a
+ build state object, and with and without a replica;
- 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.
+- **Retention:** pinned builds and builds named by build state survive
+ pruning. An unreadable record or build state prunes nothing for the app.
+ The console refuses the settings while any box is on an older binary.
+- **Promotion binding:** a reused version name with a replayed promotion
+ record and a different manifest is refused before staging.
+- **Request replay:** a completed request isn't run again on a destination
+ box. A request interrupted after resolving installs the same T. An
+ existing instance with a replica but no build state is held as unknown,
+ never moved to the head.
- **Root pass:**
- - a manual or major-held instance on a fresh box installs `held_build`,
- not the head;
+ - a manual or major-held instance on a fresh box installs the build
+ state's installed 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:** "Update now" bumps the request id, and the console never
+ chooses a build. 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*.
@@ -268,3 +317,12 @@ build whatever these settings say.
| 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 |
+
+## 10. Astra S2 findings (rev 3), answered by rev 4
+
+| # | Finding | Answer |
+|---|---------|--------|
+| S1-1 (partial), 1 | A durable promotion authorizes reused names | §4.1: the promotion record binds the manifest digest; root checks it before staging |
+| S1-2 (partial), 2 | Status-derived `held_build` can roll a manual instance back | §4.2: build state written under the lease in step with each install, replacing `held_build`; §4.3: unknown selection is held, never moved to the head |
+| S1-3 (partial), 3 | Older housekeepers delete protected builds | §4.4: the console refuses the settings until every box can protect them; retention goes off before any rollback |
+| 4 | A completed install request runs again after a move | §4.3 rule 2: {R, T} and completion persist locally and in the build state; resolved requests replay to the same T |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-SETTINGS-S2.md b/docs/superpowers/specs/CODEX-FINDINGS-SETTINGS-S2.md
new file mode 100644
index 000000000..3735f22d3
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-SETTINGS-S2.md
@@ -0,0 +1,39 @@
+REJECT
+
+S1-1: PARTIAL — Signed evidence is required, but durable authorization remains vulnerable to version-name reuse; finding 1.
+S1-2: PARTIAL — `held_build` supplies a recovery target, but asynchronous status can preserve the wrong one; finding 2.
+S1-3: PARTIAL — Updated pruners protect records, but an older housekeeper can still delete protected builds; finding 3.
+S1-4: CLOSED — A distinct request distinguishes “Update now” from enabling manual mode.
+S1-5: CLOSED — Root’s own bake clock decides request eligibility.
+
+1. **HIGH — §4.1: Durable promotion authorization can authorize replacement bytes that were never promoted.**
+
+ **Code:** [internal/release/release.go:632](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/release.go:632) explicitly permits different content under a pruned version name. [internal/release/adopt.go:1421](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/adopt.go:1421) checks artifact bytes against the unsigned manifest. Hosts have deployment-bucket write access at [infra/modules/region-host/main.tf:166](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/infra/modules/region-host/main.tf:166).
+
+ **Failure:** V was legitimately promoted, then leaves pointer history and is pruned. A compromised box uploads arbitrary bytes and a matching manifest under V, restores V’s previously valid signed promotion record if necessary, and requests V. The record authorizes `(account, app, V)`; every specified check passes on a fresh box. The installed bytes were never authorized. This exceeds the accepted freedom to select another authorized build.
+
+ **Smallest fix:** Bind the promotion record to the manifest digest, including artifact digests, and verify that binding before staging. Deleting the record during pruning is insufficient because its signature can be replayed.
+
+2. **HIGH — §§4.2–4.3: Status-derived `held_build` can roll a manual instance back during recovery.**
+
+ **Code:** [internal/status/fleet.go:111](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/status/fleet.go:111) specifies five-minute status publication plus jitter. [internal/console/apiinstances.go:383](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/console/apiinstances.go:383) reads the reported version; [internal/registry/registry.go:1720](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/registry/registry.go:1720) stamps installation independently.
+
+ **Failure:** H last reported A. Automatic adoption installs B, which starts serving. Before the next report, the owner enables manual mode; the console records reported A as `held_build`, while B correctly remains installed. The box then fails. On its replacement, rule 3 installs A against B’s database. This can cause an outage after a migration incompatible with A. Missing status is also unsafe: rule 3 treats an existing instance with no recorded build as new and installs the head.
+
+ **Smallest fix:** Have root capture and durably acknowledge the held selection when applying manual mode or completing a held instance’s installation. Recovery must distinguish “never initialized” from “selection unknown”; unknown must not select the head. No stronger authentication is needed under decision 5.
+
+3. **HIGH — §§4.4–4.5: Rolling upgrades and rollback leave a destructive retention path on older boxes.**
+
+ **Code:** [internal/host/agent.go:2721](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/host/agent.go:2721) makes retention a transferable housekeeper duty. [internal/host/retention.go:33](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/host/retention.go:33) protects only that box’s installed/previous versions before invoking shared-store pruning.
+
+ **Failure:** Upgraded box X hosts H pinned to old, untagged A. Older box Y hosts the same app on recent builds and holds—or later acquires—the housekeeper lease. Once A leaves history and the retention window, Y deletes it despite H’s provision-record pin. H’s settings remain enforced on X, so §4.5’s per-instance warning does not expose the problem. H’s next move or restore fails.
+
+ **Smallest fix:** Require every eligible housekeeper to be upgraded before enabling these settings. Disable affected-app retention before rolling any eligible housekeeper back to an incompatible version. State this ordering explicitly in §4.5.
+
+4. **MEDIUM — §4.3 rule 2: A completed install request executes again after a move or restore.**
+
+ **Code:** [internal/provision/reconcile.go:394](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/provision/reconcile.go:394) constructs the destination route from the provision record. [internal/release/versions.go:290](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/versions.go:290) reports no installed version without a local live link. Neither transfers the proposed `/opt/carlos/consumed/<host>` marker.
+
+ **Failure:** Manual H completes request 7, installing B on X; `held_build=B` is fully reported. The channel later advances to eligible C. H moves to fresh box Y, where no consumed marker exists. Rule 2 treats request 7 as pending and installs C, overriding manual mode before recovery can select B. Separately, interruption after stamping B but before recording consumption can repeat the request against a later head.
+
+ **Smallest fix:** Persist the request’s resolved target and completion with instance state that survives relocation. Replays of one request must finish the same selection; completed requests must remain completed on another box.
\ No newline at end of file