spec: instance update settings rev 2 — settings are a preference, root applies them (Paul's threat-model ruling)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+98
−143
docs/superpowers/specs/2026-10-07-instance-update-settings-design.md+98 −143
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 d7101ee75..f6266799d 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,11 +1,16 @@ |
| # Instance update settings: auto-update, channel, major hold, pin |
| -Status: draft, split out of the zero-downtime deploys spec |
| -(`2026-10-07-zero-downtime-deploys-design.md`) at its rev 6, on |
| -2026-10-07. **Not ready for review.** Astra's round 6 on that spec |
| -(`CODEX-FINDINGS-ZERO-DOWNTIME-R6.md`) rejected the directive design below |
| -with 6 findings, all about these settings. §7 lists them with the answer |
| -proposed for each. They are the starting point for the next revision. |
| +Status: draft rev 2, 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. |
| + |
| +Paul's ruling on 2026-10-07 settled the threat model: **which authorized |
| +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. |
| ## 1. Why |
| @@ -27,6 +32,11 @@ These were settled with Paul on 2026-10-06/07. |
| 3. **The app policy lives in app settings.** |
| 4. **By default instances may change nothing**, so existing apps behave as |
| they do today. |
| +5. **The settings are a preference, not a security boundary** (Paul, |
| + 2026-10-07). Root keeps the boundary that matters: only signed, shipped |
| + builds of an app reach a box. Within those builds, the instance's |
| + settings choose which one, and they are trusted the way the record's |
| + channel is trusted today. |
| ## 3. Settings and policy |
| @@ -40,7 +50,7 @@ record, and boxes read it through reconcile as they do today. |
| | `updates` | `auto` / `manual` | With `manual`, nothing installs on its own. The installed build stays put, across sleep and wake, until someone installs or pins. | |
| | `channel` | an existing channel | What the instance follows. It already exists per instance (`Record.Channel`). | |
| | `major` | empty or an integer ≥ 0 | Follow the channel, but hold a release whose semver tag is absent or has another major. It is only accepted with `stable`, because only stable promotions cut tags (`stableTag`, `internal/release/release.go`). | |
| -| `pin` | empty or a build version | Be on exactly this build (§5). It must name a build the app has shipped. | |
| +| `pin` | empty or a build version | Be on exactly this build (§4). It must name a build the app has shipped. | |
| There is no "download automatically". A box fetches a build once and every |
| instance on it shares it, so an instance has nothing of its own to |
| @@ -68,113 +78,67 @@ download. |
| box prefix, which is only a pseudonym (`internal/edge/storedoor.go`). So |
| neither of those is used for writes. |
| - The control socket offers **read only**: `GET /updates` answers from the |
| - box's copy of the record (§9.3). In-app writes ship once #155 step 6 has |
| + box's copy of the record (§5.1). In-app writes ship once #155 step 6 has |
| landed. Until then an app can show its update state but not change it. |
| - No console principal has "access to this instance but not its app", so |
| this spec doesn't invent one. Tenants without Carloku accounts act through |
| their app. |
| - A refused change names the setting, and says the app keeps it for itself. |
| -## 4. Who decides which build an instance installs |
| - |
| -**An instance's build is decided by the console, which owns its settings |
| -and policy, and is verified by root**, by the same signature scheme that |
| -already protects channel pointers. The box never makes this decision, so a |
| -sandboxed agent can't steer root, and nothing depends on the agent and |
| -root agreeing. |
| - |
| -### 4.1 Two ways a build is chosen |
| - |
| -- **Channel-following instances** have every setting at its default: |
| - `auto`, the app's channel, no `major`, no `pin`. They are adopted |
| - exactly as today. Root reads the signed channel pointer, applies bake |
| - holds (`internal/release/bake.go`) and `FastTrack`, stages the build and |
| - repoints. Most instances are like this, and their install path doesn't |
| - change. |
| -- **Directed instances** have any non-default setting, or a pending |
| - install by hand. For each of these the console keeps a **signed install |
| - directive** on the instance's provision record. Root installs exactly |
| - the directive's build and ignores the channel for that host. |
| - |
| -### 4.2 The directive |
| - |
| -A directive is signed with the key that signs channel pointers. It binds: |
| -- account, app and host; |
| -- the host's **incarnation** (`hostclaim`), so a directive for a deleted |
| - and recreated host is void; |
| -- the build; |
| -- a **revision**, which only ever increases for each host. |
| - |
| -When a pin is set, the directive's revision is the **pin revision**. |
| - |
| -**What root checks.** Before acting on a directive, root: |
| -1. verifies the signature, the account, app and host, and the |
| - incarnation it sees for the host; |
| -2. checks that the revision is not lower than its **high-water mark** for |
| - that host. The mark is kept root-owned at |
| - `/opt/carlos/directives/<host>`, which the agent can't write. |
| - |
| -Then it stages the build (download and sha256 check, as `ensureStaged` |
| -does), repoints, stamps, and raises the mark. Unsigned, mismatched or |
| -replayed directives are refused and reported in box status. The agent |
| -copies the record's directive into the registry, as reconcile copies other |
| -record fields. Because of the signature, the agent can carry the |
| -directive but can't forge one. |
| - |
| -### 4.3 How the console computes a directive |
| - |
| -The console recomputes an instance's directive whenever its settings, its |
| -app's policy, or its channel head change. Promotions already go through |
| -the console. Take the first rule that applies: |
| - |
| -1. **`pin` set:** the pinned build. Setting a pin is itself an install |
| - request, so it applies even in `manual`. No bake hold applies. |
| -2. **`updates = manual`:** the build the instance has installed now, as |
| - last reported in box status. This never moves the instance on its own. |
| - A new instance with nothing installed gets the channel head. |
| -3. **`major` set, and the channel head's tag is absent or has another |
| - major:** the installed build. The instance is *held at major N*. |
| -4. **Otherwise:** the channel head, after bake holds computed from the |
| - promotion time with `DefaultHolds`, and `FastTrack`. |
| - |
| -Box status is only a report. **For rules 2 and 3, the console accepts a |
| -reported build only if the app's signed history shows it was shipped to |
| -the instance's channel, or it was pinned for that host.** So a lying box |
| -can at most choose among builds already authorized for that instance. |
| - |
| -**Installing by hand** on a pinned instance is refused, naming the pin. |
| -Otherwise it issues a directive to the channel head, ignoring `manual`, |
| -`major` and bake holds. When an instance's settings return to default and |
| -its directive names the channel head, the console drops the directive, and |
| -the instance goes back to following the channel. |
| - |
| -### 4.4 Which routes this covers |
| - |
| -Settings, directives and the scheduler apply to exec routes only. |
| -Named-unit routes, the console's own route included, keep today's root |
| -adoption and its privileged restart unchanged (§13). The console refuses |
| -update settings on a named-unit route, saying they aren't supported there |
| -yet. |
| - |
| -### 4.5 Rollback and moves |
| - |
| -**A box rolled back to a binary from before directives** ignores them and |
| -adopts every host from its channel. So `manual`, pins and major holds stop |
| -being enforced there. The console compares each directed instance's |
| -reported installed build with its directive, flags any mismatch on the |
| -instance and in the roll docs, and enforcement resumes with the next |
| -forward roll. |
| - |
| -**Moves and restores need no state carried with them.** The directive |
| -lives on the console-owned record, so the destination installs exactly |
| -what the source was told to install. |
| - |
| -**Replay.** On a box that has never applied a directive for a host, an |
| -older directive for the same incarnation could be replayed by someone |
| -with write access to the record. It could only install a build that host |
| -was previously directed to. The high-water mark refuses anything older |
| -from the first application on, and the console's mismatch check flags |
| -it. |
| +## 4. How root applies the settings |
| + |
| +The settings ride on the provision record, which the console writes and |
| +boxes read through reconcile, the same path the record's `channel` takes |
| +today. They reach the registry beside the route's channel. **Root's adoption |
| +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*. |
| +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 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. |
| + |
| +**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. |
| + |
| +**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. |
| ## 5. Surfaces |
| @@ -192,7 +156,7 @@ it. |
| - **Console API:** `GET` and `PUT /api/v1/instances/<host>/updates` and |
| `POST …/updates/install`. They take app-member credentials, or the |
| - instance-bound credential for that host (§4.4) limited to |
| + instance-bound credential for that host (§3.3) limited to |
| `instance_control`. The instance-bound half ships with #155 step 6. |
| - **Control socket:** `GET /updates`, mounted beside `schedule.Mount`'s |
| verbs with the same bearer and fixed-host rule. It returns the box's copy |
| @@ -206,37 +170,28 @@ scheduler, swap, failure records and fallback then act on that build, and |
| need nothing else from this spec. A failure record from that spec holds a |
| build whatever these settings say. |
| -## 7. Open: Astra round 6 findings, with proposed answers |
| - |
| -1. **The console signs a manual target taken from unauthenticated box |
| - status.** *Proposed:* the console never derives a target from status. |
| - Turning on `manual` freezes the directive at the console's own record of |
| - what it last directed, or the channel's eligible head for a |
| - channel-following instance. That target is kept across recomputation, |
| - and only an install by hand or a pin changes it. |
| -2. **Removing a directive brings back channel adoption without a |
| - signature.** *Proposed:* leaving directed mode is itself a signed |
| - directive of kind `follow-channel`, at the next revision. Root keeps |
| - each host's mode next to its high-water mark, and installs nothing for |
| - a host that has a mark but no directive at that revision or above. |
| -3. **A directive replaced while root is staging.** *Proposed:* root |
| - re-checks the directive's revision under the host guard just before |
| - repointing, and reconcile publishes directives under that same guard. |
| - The order is repoint, stamp, then raise the mark, and a pass recovers |
| - any step it finds half done. |
| -4. **Directed instances skip the app's own bake windows.** *Proposed:* |
| - `major` and auto-following directives become `follow-channel` with a |
| - major ceiling. Root keeps applying the box's pipeline bake as it does |
| - today and refuses heads past the ceiling, so the console never computes |
| - bake. |
| -5. **Stale status can undo an install by hand.** *Proposed:* this goes |
| - away with answer 1, because status is never an input. |
| -6. **Recomputation has no complete trigger.** *Proposed:* with answer 4, |
| - the console only recomputes when settings change, not when a channel |
| - moves or a bake expires. That needs no reconciliation loop. |
| - |
| -**The threat model has to be decided before the next round.** Is |
| -"which authorized build an instance runs" a boundary against its own |
| -box's agent? That agent already runs the instance and could exec any |
| -staged build directly. If the answer is no, findings 2 and 3 are about |
| -correctness, not security, and simpler fixes will do. |
| +## 7. Astra round 6 findings, answered by rev 2 |
| + |
| +| # | 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) | |
| +| 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) | |
| +| 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 |
| + console, the CLI and the instance-bound credential. |
| +- **Rollback:** a box whose root predates this feature shows *settings |
| + not enforced*. |