spec: instance update settings rev 5 — answers Astra S3 (digest-bound evidence for every new install, last_executed written before exec, returning residence)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+124
−28
docs/superpowers/specs/2026-10-07-instance-update-settings-design.md+92 −28docs/superpowers/specs/CODEX-FINDINGS-SETTINGS-S3.md+32 −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 486491912..b213cb52c 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 4, 2026-10-07. Split out of the zero-downtime deploys |
| +Status: draft rev 5, 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. |
| @@ -15,8 +15,9 @@ findings. Astra closed all six on rev 2 and raised five new ones |
| the only judge of eligibility**, recording only facts on the record, and |
| 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). |
| +and left three partial. Rev 4 answered those three and one new finding |
| +(§10). S3 (`CODEX-FINDINGS-SETTINGS-S3.md`) closed one and left three |
| +partial. Rev 5 answers them (§11). |
| ## 1. Why |
| @@ -118,6 +119,22 @@ that V was authorized for A:** |
| signs pointers. The digest is the sha256 of the canonical manifest bytes, |
| which carry each artifact's sha256. |
| +**Which evidence applies.** Only today's ordinary adoption of the |
| +**current channel head** may rely on the signed pointer, as it does today. |
| +**Every install this spec adds needs the digest-bound promotion record:** |
| +- a pin; |
| +- a resolved install request whose build is no longer the head; |
| +- a recovery install of the build state's build. |
| + |
| +Pointers sign version names without digests, so a replayed old pointer |
| +plus a reused version name could authorize bytes nobody promoted. That |
| +weakness exists in the release chain today, independent of this spec. It |
| +is filed as #253, and this spec never widens it. |
| + |
| +**Backfill.** When this feature rolls out, the console writes promotion |
| +records for every version still in each channel's signed history. Their |
| +digests are taken from the canonical manifests at that time. |
| + |
| 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 |
| @@ -140,21 +157,37 @@ is the zero-downtime spec's retained identity. |
| | `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 | |
| -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. |
| +The **build state** object holds two things: |
| +- **`last_executed`**: the build whose process last opened the instance's |
| + database; |
| +- **the install request root last resolved**, as {R, T}, and whether it |
| + has completed. |
| + |
| +**When it is written.** Before any generation runs a build X against the |
| +instance's database, the agent writes the build state with |
| +`last_executed = X` and the current request state. That covers a wake, a |
| +swap's successor, a fallback, a worker or sidecar spawn, and a recycle. |
| +The agent holds the instance's lease at that point: a wake takes the lease |
| +before it spawns, and a swap keeps it. If the write fails, the generation |
| +doesn't start. The wake fails as a wake fails today, or the swap is |
| +abandoned. |
| + |
| +So **the build state always names the build that last touched the |
| +database**, or one about to. A cold install by root on a sleeping instance |
| +doesn't touch the database and doesn't change the build state. If the box |
| +is lost before that instance wakes, recovery installs the build the data |
| +was last used with, which is right. |
| + |
| +**Lost requests.** Root records {R, T} locally before staging (§4.3). If |
| +the box is lost after resolving R but before T ever ran, nothing was |
| +written and T never touched the database. The replacement box resolves R |
| +again, against the head at that time. That is an install request doing |
| +what it says. |
| + |
| +The object is trusted the way settings are (§2 decision 5). A forged object |
| +can at most choose another authorized build of the same app (§4.1). The |
| +agent keeps a copy in the registry beside the route, refreshed at every |
| +reconcile and before every wake, and root reads that copy. |
| ### 4.3 The rules |
| @@ -181,16 +214,28 @@ build its box holds as failed unless it has already served that build. |
| - **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. |
| - - **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. |
| + another major:** the instance is **held**, and its target is: |
| + - the build state's **`last_executed`**, if there is one. If the build |
| + installed on this box differs (a move, a restore, or a return to a |
| + box the instance left with an old live link still in place), root |
| + installs `last_executed`, if §4.1 authorizes it. Bake doesn't apply, |
| + because the data was last used with that build. |
| + - **No build state, but a build installed on this box:** that build. |
| + This is today's state on a box upgraded from before this feature. |
| + - **No build state and nothing installed, but the instance has a |
| + replica:** **unknown**. Install nothing, report *build unknown: use |
| + Update now or set a pin*, and leave the instance down rather than |
| + guess. |
| + - **None of these:** the instance is new and gets the eligible channel |
| + head. |
| + |
| + **The wake checks too.** Before it spawns a held instance, the agent |
| + compares the installed build with the build state's `last_executed`. If |
| + they differ, it doesn't run the installed build. It runs |
| + `last_executed` from its staged path if the box has it. Otherwise it |
| + touches the stage relay and holds the request until root has installed |
| + it, within the wake's normal bound. So a stale live link never runs |
| + against data a newer build has used. |
| 4. **Otherwise:** today's adoption, unchanged. |
| The console refuses "Update now" on a pinned instance, naming the pin. |
| @@ -296,6 +341,16 @@ build whatever these settings say. |
| 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. |
| +- **Build state before execution:** every spawn path writes |
| + `last_executed` under the lease before exec, and a failed write blocks the |
| + spawn. A cold install on a sleeper leaves the build state alone, and |
| + recovery after losing that box installs the previous build. |
| +- **Returning residence:** a held instance moving back to a box with a |
| + stale live link installs `last_executed`. The wake never runs the stale |
| + link. |
| +- **Evidence:** a pin of a pruned and reused version name, with a replayed |
| + pointer, is refused, because it lacks a matching promotion record. |
| + Backfill writes records for builds still in history. |
| - **Root pass:** |
| - a manual or major-held instance on a fresh box installs the build |
| state's installed build, not the head; |
| @@ -326,3 +381,12 @@ build whatever these settings say. |
| | 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 | |
| + |
| +## 11. Astra S3 findings (rev 4), answered by rev 5 |
| + |
| +| # | Finding | Answer | |
| +|---|---------|--------| |
| +| S2-1 (partial), 1 | The pointer path bypasses the digest binding | §4.1: every install this spec adds needs the digest-bound promotion record; only today's head adoption uses the pointer. The pointer-replay weakness predates this spec and is filed separately; backfill is defined | |
| +| S2-2 (partial), 2 | The build state is written after execution can begin | §4.2: `last_executed` is written under the lease before any generation runs, and a failed write blocks it; cold installs don't touch it; losing a resolution before T ran is harmless | |
| +| S2-4 (partial) | A locally resolved request is lost before it is mirrored | §4.2 *Lost requests*: T never ran, so the replacement resolves again | |
| +| 3 | Returning to a previous box keeps a stale installation | §4.3 rule 3: the held target is `last_executed`, root reinstalls on a mismatch, and the wake refuses a stale link | |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-SETTINGS-S3.md b/docs/superpowers/specs/CODEX-FINDINGS-SETTINGS-S3.md| new file mode 100644 |
| index 000000000..8a600d428 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-SETTINGS-S3.md |
| @@ -0,0 +1,32 @@ |
| +REJECT |
| + |
| +S2-1 / carried S1-1: **PARTIAL** — Promotion records bind digests, but the alternative pointer path bypasses that binding. |
| +S2-2 / carried S1-2: **PARTIAL** — Build state replaces status, but installation ordering and returning residences remain unsafe. |
| +S2-3 / carried S1-3: **CLOSED** — The upgrade gate and retention-off rollback ordering address older housekeepers. |
| +S2-4: **PARTIAL** — Mirrored requests survive relocation; locally resolved requests can still be lost before mirroring. |
| + |
| +1. **HIGH — §4.1: The pointer alternative bypasses the new digest binding.** |
| + |
| + **Code:** [internal/release/release.go:119](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/release.go:119), [release.go:632](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/release.go:632), [internal/release/adopt.go:1421](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/adopt.go:1421). |
| + |
| + **Failure:** Save a valid signed pointer naming V. After V leaves history and is pruned, upload arbitrary replacement bytes and a matching unsigned manifest under V, then replay the saved pointer. §4.1 permits authorization through that pointer alone: it signs version names, without manifest digests. Root therefore accepts the replacement even though V’s promotion record would reject its digest. These bytes were never authorized; this exceeds decision 5. |
| + |
| + **Smallest fix:** Require digest-bound signed evidence for every fresh installation. A digest-less pointer must not substitute for the promotion record; define migration for legacy releases. |
| + |
| +2. **HIGH — §§4.2–4.3, §6: Writing build state before reporting completion does not protect execution.** |
| + |
| + **Code:** [internal/release/adopt.go:1072](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/adopt.go:1072), [adopt.go:1258](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/adopt.go:1258), [internal/activator/activator.go:1153](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/activator/activator.go:1153). |
| + |
| + **Failure:** Root installs and stamps B while the bucket’s build state still names A. The accepted scheduler consumes that stamp independently; the spec requires mirroring before *reporting* completion, without requiring it before execution. B can run and replicate a database migration before the mirror succeeds. If the box fails, manual recovery installs A against B’s data. The unknown-selection hold does not help because valid, stale state exists. Likewise, losing locally resolved `{R,B}` before mirroring lets the replacement resolve R against a later head. |
| + |
| + Sleeping installs expose the same coordination gap: root installs without waking, while the instance lease required for the agent’s write is acquired during wake. |
| + |
| + **Smallest fix:** Durably persist request resolution before staging and the recoverable installed selection before admitting execution. Define the root/agent acknowledgement and sleeping-instance lease path; recovery must honor any pending durable selection. |
| + |
| +3. **HIGH — §4.3 rule 3: Returning to a previous box preserves its obsolete installation.** |
| + |
| + **Code:** [internal/host/movespass.go:491](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/host/movespass.go:491), [internal/registry/registry.go:832](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/registry/registry.go:832), [internal/release/versions.go:290](/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys/internal/release/versions.go:290). |
| + |
| + **Failure:** Manual instance H runs A on X, moves to Y, is pinned to B, then has its pin cleared while remaining manual. Its durable build state correctly names B. H moves back to X. Departure purged X’s database and removed its registry row, but retained its live symlink to A. Rule 3 consults build state only when nothing is installed locally, so X keeps A and boots it against the restored B database. No request replay or malicious actor is needed. |
| + |
| + **Smallest fix:** On relocation or database restore, reconcile the installation against durable build state before execution. Invalidate local selection on departure, or bind it to the current residence; a surviving symlink alone cannot establish the held build. |
| \ No newline at end of file |