carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 3bc3189d57a2bb46fbbddffc8f0e9d20463d1a2f parent 2fb0dc0
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*.