carlos / platform Public

spec: carlos system update — the platform rolls itself

Design for fleet self-rolling of the platform binary from a signed
channel pointer (control/system/router/<channel>.json), mirrored into
the deployment bucket by the console from the public signed release,
applied per box by a root oneshot with a journal, health gate and
rollback to carlos.prev. Two channels, two promotes; third-party
deployments in v1. Retires update-platform-binary.sh.

Reviewed in three rounds by two independent readers against the code;
§13 records every finding and where it landed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
Paul Campbell pushed by paul@keymail.dev a49b162ba7abde083a062ab4a7873a98e654fb51 parent d1cfc65
1 file changed, +941 −0
  • docs/superpowers/specs/2026-09-06-carlos-system-update-design.md +941 −0
diff --git a/docs/superpowers/specs/2026-09-06-carlos-system-update-design.md b/docs/superpowers/specs/2026-09-06-carlos-system-update-design.md
new file mode 100644
index 000000000..447bd811b
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-06-carlos-system-update-design.md
@@ -0,0 +1,941 @@
+# `carlos system update` — the platform rolls itself
+
+**Date:** 2026-09-06 · **Status:** draft, two review rounds (Fable, Codex),
+awaiting Paul · **Requested by:** Paul
+
+**Builds on:** discussion #100 (boxes should self-update via a channel
+subscription), 2026-08-23-console-as-an-app-design.md (slice 3, deferred
+there; the dividing line this spec applies), the root-relay pattern
+(`carlos-stage.path` → `carlos adopt --stage-only`), and pinfra's
+`update-platform-binary.sh`, whose 85 lines are the procedure this spec
+moves into the binary and then retires.
+
+## 0. How this spec was arrived at
+
+One brainstorm with Paul on 2026-09-05/06, after a merge of `main` stalled
+on "and deploy": the only way to roll a platform binary today is an
+operator with SSM access to every box, running a script per box, in an
+order held in operator memory. Paul's framing, which is the requirement:
+
+> Ultimately, a fleet operator should be able to do `carlos system update`
+> and the whole platform updates to the latest version based on what we
+> just released. […] And I guess each piece should be updatable
+> individually, `carlos system router update` etc.
+
+Three questions settled the shape before any design was written:
+
+- **Trigger: promote-driven.** A fleet operator says when. Boxes do not
+ poll the public release feed and roll on their own; a new release exists
+ in the world for as long as the operator wants before any box sees it.
+- **Rollout: two channels, two promotes.** `canary` and `stable`, one
+ operator action each, exactly like an app. No auto-promotion in v1.
+- **Third-party deployments in v1.** Tito's operator rolls Tito's boxes
+ from Tito's console. The artifact comes from the public signed release,
+ so the vendor never needs access to a customer box (the trust shape #100
+ was filed about).
+
+Three approaches were compared; this document is the first. The other two
+are recorded in §12. The draft went through two rounds of review by two
+independent readers checking claims against the code (§13); everything
+below is the post-review shape, and §13 says what the drafts got wrong.
+
+One ruling stated up front because it changes what "each piece" means:
+the edge, the activator and the adoption loop are **one process**
+(`carlos agent`, unit `carlos-edge.service`). There is nothing to update
+"individually" inside it without splitting the process, which is a
+separate project. So the honest component list today is one: the
+**router**, which is the substrate binary. The console is already an app
+and is updated as one (`carlos deploy --app console`). §3 keys everything
+per component so a split later adds a name, not a schema.
+
+---
+
+## 1. What exists, and what is missing
+
+The platform already has most of this machinery, built for apps:
+
+| Have | Where | Reused as |
+|---|---|---|
+| Signed channel pointer, CAS-written, single parse site | `release.ChannelPointer`, `signPointer`/`verifyPointer` (`release.go:102,350,397`) | The platform pointer's wrapper, signing and verification |
+| The one unsigned "something changed" signal, polled at 2s | `release.BumpSerial` on `control/serial` (`serial.go:51`), `fastConvergeLoop` (`agent.go:1412`) | How a box learns there is a new platform pointer |
+| Root relay from a sandboxed agent | `/data/stage-requested` → `carlos-stage.path` → `carlos-stage.service` | Same shape, new marker, new oneshot |
+| Fleet-box bucket proxy with an explicit grant for the platform binary | `storedoor.go:383` `aclRead`: exact `bootstrap/carlos`, "the sanctioned self-update path (F7)" | Boxes behind the door fetch the artifact with no public egress |
+| Verified download of a public release | `update.go` `selfReplace`: tag-pinned URL, `SHA256SUMS.txt.sig` over the sums file, per-asset sha, lie-detector `version` run | The console's ingest of a release |
+| A box's running build, published to the bucket | `version.Build` → `BoxMetrics.Release` in `status/boxes/<label>.json` every 5 min (`agent.go:2318`) | `carlos system status` and the fleet-side half of the gate |
+| The unit list a box must have armed, and the exact `systemctl` sequence | `enabledUnits`, `systemctlPlan` (`bootstrap.go:72,96`): enable all, start timers and paths, **never start a service** | What apply arms, so apply and bootstrap never disagree and apply never starts itself |
+| The apply procedure | `update-platform-binary.sh` (pinfra) | Moved into `carlos system apply`, line for line, then retired |
+
+What is missing, and is the whole of this spec:
+
+1. A **pointer for something that is not an app** — no account, no route,
+ no upstream, and a version that is a platform tag, not a release
+ serial.
+2. A **box-side leg** that notices the pointer, fetches and verifies the
+ artifact, and asks root to apply it.
+3. A **root apply** that does the script's dance from inside the binary,
+ with the three things the script does not have: a **journal** so an
+ interrupted apply is undone rather than left half-done, a **health
+ gate**, and a **rollback**. Today `carlos-edge.service` is
+ `Restart=always`, `RestartSec=1`, with no `StartLimit*` of its own, so
+ a binary that dies on start hits systemd's default limit (5 starts in
+ 10 s) and the unit goes to `failed` with start-limit-hit; nothing puts
+ the old binary back, and there is no `.prev` anywhere.
+4. A **verb** an operator runs, and a **status** they read.
+
+---
+
+## 2. The model: the platform is a subscriber of its own releases
+
+One sentence: **a platform pointer names a version and its artifacts; a
+box whose channel pointer names a version other than the one it is
+running fetches, verifies, and asks root to swap and restart; root
+journals every step, verifies everything again from a copy it owns, and
+puts the old binary back if the new one does not come up and stay up.**
+
+Four things the model deliberately does *not* do:
+
+- **A box never decides to update.** It reacts to a pointer the deployment
+ key signed, placed by an operator's promote. No box reads the public
+ release feed.
+- **A box never publishes the binary.** `aclWrite` already refuses
+ `bootstrap/**` for boxes (`storedoor_test.go:81`), and stays that way.
+ Only the console, holding the bucket and the signing key, writes the
+ artifact and the pointer.
+- **Root takes exactly one input from the sandboxed user, and treats it
+ as a hint.** `/data` is `chown -R carlos:carlos` (`bootstrap.go:390`):
+ any path under it can be a symlink, any file can be swapped between
+ check and use. So root reads one file at one fixed path, walking to it
+ component by component with no symlinks followed, and everything root
+ then trusts — which channel, which version, which sha, which key —
+ comes from a pointer root fetched and verified itself and from root's
+ own `host.env`. The candidate binary is copied into a directory only
+ root can enter, verified there, run there, and installed from there.
+ A compromised `carlos` user can make the box install exactly what the
+ deployment key signed for the box's configured channel, and nothing
+ else — the trust the stage relay already grants, made true by
+ construction.
+- **Root never leaves the box between two builds.** Every step that
+ changes the box is journaled before it runs; a killed oneshot, a
+ timeout, or a reboot mid-apply is recovered — always backward, to the
+ build that was known good — before anything else happens (§6).
+
+---
+
+## 3. The pointer: `control/system/<component>/<channel>.json`
+
+Placed under `control/`, beside `control/serial`, because that is where the
+deployment's platform-wide signals live, and explicitly **not** under
+`apps/` — the console-as-an-app spec's line is *anything adoptable through
+a route is an app*, and this is not.
+
+```go
+// SystemPointer is the platform's channel pointer. It is what a
+// ChannelPointer is for an app, for the thing that does the adopting.
+type SystemPointer struct {
+ Schema int `json:"schema"` // 1
+ Component string `json:"component"` // "router"
+ Channel string `json:"channel"` // "canary" | "stable"
+ Current SystemRelease `json:"current"`
+ History []SystemRelease `json:"history"` // newest first; rollback targets
+ Spread string `json:"spread"` // "5m": boxes jitter their start over this
+ PromotedAt time.Time `json:"promoted_at"` // of THIS write; the replay guard reads this one
+ Audit []Promotion `json:"audit"` // reused from release.go
+}
+
+// SystemRelease is everything a box needs to install one version. It
+// travels whole into History so a rollback re-installs what was there,
+// not a version string with someone else's artifacts.
+type SystemRelease struct {
+ Version string `json:"version"` // what `carlos version` must print
+ Artifacts map[string]Artifact `json:"artifacts"` // "linux-arm64", "linux-amd64"
+ Source string `json:"source"` // "release:v0.18.0" | "file"
+ ReleasedAt time.Time `json:"released_at"` // when first mirrored; audit only
+}
+
+type Artifact struct {
+ Key string `json:"key"` // bootstrap/router/<version>/carlos-linux-arm64
+ SHA256 string `json:"sha256"` // hex, of the object at Key
+}
+```
+
+- **Signed with the deployment's release key**, using the same
+ `signedWrapper` (payload, sig, optional grant) and the same root-key
+ anchor `CARLOS_RELEASE_PUBKEY` a box already holds. **Root key only in
+ v1**: a scoped grant's rungs name app channels, and a grant that could
+ roll the platform is a bigger key than any app promoter should hold.
+ `verifyPointer` grows a sibling `verifySystemPointer`; both share the
+ wrapper parse, which stays the single parse site.
+- **CAS-written** with If-Match like `ChannelKey`, so two operators cannot
+ race each other into a lost promote.
+- **Every write sets `PromotedAt` to now** — promote and rollback alike.
+ The box's replay guard (§6) reads this field and only this field. A
+ rollback that carried the old release's timestamp would be refused by
+ every box as a replay of itself; hence the two differently named
+ fields.
+- **`Version` is a string the box compares for equality** with
+ `version.Build`. For a public release that is the tag (`v0.18.0`, which
+ release builds put in `Build`); for a `--file` promote (§5) it is whatever
+ the operator names, which `carlos system apply` checks by running the
+ staged binary's `version` before installing it. Dev deployments that roll
+ git-sha builds keep working; nothing assumes semver.
+- **A version is immutable.** The console refuses to mirror different
+ bytes under a `bootstrap/router/<version>/` that already exists
+ (§5 step 3). Without that, a reused `--file --version` could swap the
+ artifact under a pointer stable boxes are still on, and boxes already
+ reporting that version would never take the replacement.
+- **`Artifacts` is per platform** because BYO boxes are not all Graviton.
+ A box picks `runtime.GOOS-runtime.GOARCH`; a pointer with no entry for
+ the box's platform is "no update for me", logged once, not an error.
+- **`Spread`** is the one fleet-policy knob. A box that sees a new pointer
+ waits `rand(0, Spread)` before requesting the apply, so a stable promote
+ to nine boxes is nine edge restarts over five minutes, not nine in the
+ same two seconds. `--spread 0` exists for a hotfix. Home-before-region
+ ordering (#300) is *not* in v1: the canary channel, with home boxes on
+ it, is the ordering an operator needs today. **The console's own box
+ goes on canary by convention** — it hosts the store door, and a fleet
+ box mid-apply that finds the door restarting gets a transient store
+ error (§4 says what it does with one).
+- **`History`** holds whole releases, `historyMax = 10`, and is what
+ `carlos system rollback` swaps with (§5).
+
+The artifact lives at **`bootstrap/router/<version>/carlos-<os>-<arch>`**.
+`bootstrap/` because the door already means "the platform's own binary"
+by that name; per-version because canary and stable boxes must be able to
+run different binaries at the same time, which a single `bootstrap/carlos`
+cannot express. The existing exact key stays as it is until §10 retires the
+script.
+
+**Store-door changes, both reads, in `aclRead`:**
+
+1. `control/system/<component>/<channel>.json` — exact three-segment shape
+ under `control/system/`. Without it, a door-backed box gets 404, which
+ `fleetapi.Get` maps to `os.ErrNotExist` (`fleetapi.go:150`), and the
+ fleet #100 exists for silently never updates.
+2. `bootstrap/router/<version>/<asset>` — a *shape*, three segments, no
+ listing (`aclDir` does not change), and still nothing else under
+ `bootstrap/`. The existing pin that `bootstrap/carlos.sig` is refused
+ stays, and gains siblings: `bootstrap/router/../../x` and
+ `control/system/../serial` are refused.
+
+Boxes need no signature file because the sha in the signed pointer *is*
+the artifact's signature. `aclWrite` does not change: a box still cannot
+write under `control/` or `bootstrap/`.
+
+**A streaming read on the store.** `release.Store.Get` returns `[]byte`
+and every implementation buffers (`store.go:43`; `fleetapi.go:133`);
+`maxStoreBodyBytes` bounds PUT bodies only (`storedoor.go:180`), the
+door's GET is uncapped and in memory. The box must not hold a binary in
+memory under `MemoryHigh=70%`, so the interface gains
+`Open(ctx, key) (io.ReadCloser, etag string, err error)` with all four
+implementations (`S3Store`, `DirStore`, `fleetapi`, `prefixStore`).
+The door's own GET stays in memory in v1 (§9, §11).
+
+---
+
+## 4. The box: notice, fetch, verify, ask
+
+A new leg in the agent, **`systemLeg`**, with two entry points so the
+spread has something to run it:
+
+- `observe(ctx)` — called from `fastTick` on a serial move and from
+ `adoptLoop` on its hourly pass. Reads the pointer, decides, and either
+ does nothing or records a *pending* request with a deadline.
+- `tick(now)` — called on **every** 2 s fast tick, reads no store. Fires
+ a pending request when its deadline passes, and **re-touches the marker
+ once a minute while a request is outstanding** — a request root has not
+ answered in `result.json`. `PathChanged` does not replay a change made
+ before the unit was armed, and drops one that lands while the service
+ is mid-run (`carlos-restart.path`'s own comment); the stage relay
+ survives that because the unstaged route is re-observed every 2 s, and
+ this leg needs the same persistence by hand.
+
+It is sandboxed (`User=carlos`, `ProtectSystem=strict`) and can write only
+`/data` and `/run/carlos`, which is exactly enough.
+
+1. **Read** `control/system/router/<channel>.json` through the box's normal
+ store (S3, dir, or the fleet door — `openDeployStore` already picks).
+ The channel is **`CARLOS_SYSTEM_CHANNEL` in `host.env`**, default
+ `stable`; an operator puts a box on canary by editing one line and
+ restarting the edge, which is how every other per-box setting works.
+ Missing pointer → nothing to do; this is the state of every deployment
+ until its first `carlos system update`.
+2. **Verify** the pointer with the release pubkey. A pointer that does not
+ verify is logged and ignored, like a bad channel pointer.
+3. **Decide.** Read root's `seen.json` (§6; root-owned, world-readable)
+ for this channel's watermark. Then:
+ - pointer older than the watermark → ignore (root would refuse it);
+ - `Current.Version == version.Build` and pointer not newer than the
+ watermark → nothing to do;
+ - `Current.Version == version.Build` and pointer newer → a **no-op
+ request** (step 7 with `noop: true`, no artifact fetched), so root
+ sees every promote and the watermark moves without a 25 MiB download
+ on every box;
+ - otherwise → an update. A dev build (`Build == "dev"`) never matches,
+ so a hand-built binary on a dev box is never rolled over by accident;
+ it *is* rolled when the operator promotes something, which is the
+ point.
+4. **Refuse to repeat a failure.** If `result.json` names this exact
+ `(channel, version, promoted_at)` with outcome `rolled-back`,
+ `rollback-failed` or `refused`, do nothing until the pointer changes.
+ A pointer that broke one box must not have that box firing a root
+ oneshot at every serial bump. `deferred` (a transient store error on
+ root's side) is retried with the per-host backoff the unstaged-route
+ sweep uses. A terminal outcome is also when the leg **deletes its own
+ cache** under `/data/system/router/<version>/` — root never deletes
+ anything under `/data`.
+5. **Jitter** `rand(0, Spread)` from the first sighting, as the pending
+ deadline `tick` watches.
+6. **Fetch** the artifact for this box's platform with `Store.Open`,
+ reading at most 96 MiB + 1 byte — the extra byte is how an object
+ exactly at the limit is told from one over it — with a running sha,
+ into `/data/system/router/<version>/carlos.tmp` then `rename`. The
+ object is never in memory on the box; an oversized object from a
+ bucket writer is cut off at the limit, not at the OOM killer.
+ **Verify** the sha against the pointer; mismatch → delete, log, retry
+ with backoff.
+7. **Write the request** `/data/system/router/requested.json`
+ `{version, promoted_at, noop, seen_at}` — **no channel, no key, no
+ sha, no path**: root takes the channel from its own `host.env`, the
+ key and sha from the pointer, and derives the cache path from the
+ version — and **touch `/data/system-update-requested`**, throttled to
+ once a minute like `stageRequester`.
+8. **A transient store error** anywhere above (the door restarting because
+ the console box is rolling, a network blip) is a retry, not a refusal:
+ nothing is recorded, the next `observe` starts over.
+
+The marker fires **`carlos-system-update.path`** (`PathChanged`, the same
+form as `carlos-stage.path`), whose unit is **`carlos-system-update.service`**:
+`Type=oneshot`, root, `StartLimitIntervalSec=0`, `TimeoutStartSec=15min`
+(a hung apply is a failed apply), `OnFailure=carlos-system-recover.service`
+(a killed or timed-out apply is recovered without waiting for anyone to
+touch anything), the same hardening set as `carlos-stage.service` — and
+no `ProtectSystem`, because it must write `/opt/carlos` and
+`/etc/systemd/system`. A third unit, **`carlos-system-recover.service`**
+(`Type=oneshot`, `Before=carlos-edge.service`, `WantedBy=multi-user.target`,
+`TimeoutStartSec=10min`), runs once per boot and on the update unit's
+failure.
+
+**Both units exec through one line of shell:** when a journal is open and
+`carlos.prev` exists, exec `carlos.prev system apply …`; otherwise exec
+`carlos system apply …`. After the rename the binary at
+`/opt/carlos/carlos` is the *candidate*, and the one thing known about a
+candidate that needs recovering is that it did not work; the recovery
+must not depend on it. (§11 names the residual: a `.prev` that cannot
+run `system apply` either, which is a box that was already broken before
+this roll.)
+
+`enabledUnits` gains all three, and `systemctlPlan` does with them what
+it does with everything: enable all, `start` only the `.path` — never a
+service, which is why apply can call it from inside itself without
+starting a second apply.
+
+---
+
+## 5. The verbs
+
+All under `carlos system`, a new top-level dispatch beside `ops`. Every
+verb runs in **API mode against the console** (`--console`,
+`requireDeployAPISession`), because the console holds the bucket, the
+signing key, and the egress; the workstation holds none of them.
+
+**Authorization, stated once:** the endpoints
+(`PUT /api/system/upload`, `POST /api/system/update`,
+`GET /api/system/jobs/<id>`, `POST /api/system/rollback`,
+`GET /api/system/status`) require a **person session** (`apiPerson`,
+`apicli.go:298`) whose member is an **owner of the ops account**
+(`OpsAccountID`, `store.go:1158`), checked server-side on every call.
+Service credentials are refused, and so is every other account: a
+deployment has one ops account, and rolling the root binary of every box
+is that account's owners' act and no one else's. `requireDeployAPISession`
+selects console mode; it grants nothing (`deploy.go:1151`), which is why
+this paragraph exists.
+
+**The update endpoint is a job**, not a request: it returns a job id, the
+console does the fetch-verify-mirror-sign work in a goroutine, and the
+CLI polls the job and prints each step as it completes. Fetching two
+25 MiB assets from GitHub inside one HTTP request is how a promote times
+out halfway through a mirror. Jobs live in console memory; a console
+restart mid-job loses it, the CLI reports that, and re-running is safe
+because every step is idempotent (mirror skips what is present and
+equal; the pointer write is CAS). `--file` is a separate
+`PUT /api/system/upload` (body limit 128 MiB, `--platform` required
+because the console cannot run a foreign-arch binary to ask it) that
+returns a handle the update job then consumes.
+
+**`carlos system update [--channel stable] [--version vX.Y.Z] [--file path --platform linux-arm64 --version v] [--spread 5m] [--yes]`**
+
+The fleet operator's one command. It is a promote for the platform:
+
+1. **Resolve the version.** Default: `https://apt.carlosframework.com/latest.txt`
+ (the `carlos update` discovery, `releaseTag`-shaped). `--version`
+ pins one. `--file` skips step 2.
+2. **Ingest the release, exactly as `carlos update` does.** The console
+ fetches `SHA256SUMS.txt` and `.sig` from the tag-pinned
+ `updateDownloadBase`, verifies the signature with the embedded
+ `cliUpdatePubKey`, and fetches each asset the deployment needs, checking
+ its sha against the sums. The asset set is the distinct platforms in
+ every box heartbeat — `status/boxes/*.json` **and**
+ `fleets/*/status/boxes/*.json` — via a new `BoxMetrics.Platform` field
+ written alongside `Release`; a box that has not reported since the
+ field shipped counts as `linux-arm64`. `selfReplace`'s fetch-and-verify
+ is factored out into a function both callers share; it does not move.
+3. **Mirror** each asset to `bootstrap/router/<version>/<asset>`. Present
+ with the same sha → skip (a second promote of the same version to
+ another channel copies nothing). Present with a different sha →
+ **refuse the whole promote**: versions are immutable (§3).
+4. **Write the pointer** for the channel, signed, CAS, `PromotedAt` now.
+ `History` gets the previous `Current` whole; `Audit` gets a
+ `Promotion{From, To, Actor, At}`.
+5. **Bump the serial.** Boxes see it within two seconds and start their
+ spread.
+6. **Print what will happen:** which boxes are on this channel (from a new
+ `BoxMetrics.SystemChannel`, also in the heartbeat — without it the
+ console cannot know which boxes a promote reaches), running what, and
+ the spread. Then return.
+
+Without `--yes` it confirms once, showing the box list. The channel ladder
+is advisory in v1: promoting to `stable` a version that was never on
+`canary` prints a warning and proceeds, because a hotfix needs to.
+
+**`carlos system router update …`** is the same verb scoped to one
+component. With one component today it is an alias; it exists so the
+command Paul named works and so a future split needs no new verb.
+`carlos system update` with no component updates every component the
+pointer schema knows.
+
+**`carlos system status [--channel …]`**
+
+Reads the pointers and every heartbeat under both prefixes and prints one
+row per box: label, platform, channel, running `Release`, channel target,
+and the last apply outcome (§6, published in the heartbeat as
+`BoxMetrics.System`). This is the fleet-side half of the health gate: a
+box whose heartbeat still shows the old `Release` ten minutes after a
+promote is the row an operator is looking for. Heartbeats are
+five-minutely, so this is also the honest answer to "is it done yet":
+*not yet* for up to five minutes after the last box restarts. A `noted`
+outcome (a no-op request, §6) is shown as such, not as an apply.
+
+**`carlos system rollback [--channel …]`**
+
+Swaps `Current` with `History[0]` — whole releases, so the previous
+artifacts and hashes come back with the version — re-signs with
+`PromotedAt` now, bumps the serial. Nothing GCs `bootstrap/router/` in
+v1, so the objects are there. Every box rolls back through the same path
+it rolled forward on; the box-side `.prev` (§6) is for the box that
+cannot come up, not for this.
+
+**`carlos system apply [--recover]`** is the root oneshot, §6. It is not
+for operators and says so if run interactively.
+
+What is deliberately **not** a verb: `carlos system console update`. The
+console is an app; `carlos deploy --app console` is its verb and already
+works. Giving it a second name under `system` is the confusion the
+dividing line exists to prevent.
+
+---
+
+## 6. `carlos system apply`: the journal, the dance, the gate, the way back
+
+Runs as root from `carlos-system-update.service` (or, with `--recover`,
+from `carlos-system-recover.service`). Every step is one the pinfra script
+does today, plus the ones it lacks. Root state lives in
+**`/opt/carlos/system/router/`** (0755, root-owned, never writable by
+`carlos`):
+
+- `lock` — an `flock` every apply takes first. The path-triggered apply
+ takes it non-blocking and exits 0 with "another apply is running" if it
+ cannot; `--recover` waits for it. Two processes never touch
+ `/opt/carlos/carlos` at once, whatever an operator's hand-run
+ `systemctl start` does.
+- `staging/` — **0700**. The root-owned copy of the candidate and the two
+ staged unit roots. Removed on every exit, success or not.
+- `journal.json` — the open transaction, if any: `{phase, target,
+ prev_build, units_added, units_removed, started_at}`. Written before
+ each box-changing step, removed on completion.
+- `seen.json` — `{<channel>: promoted_at}`: the newest pointer root has
+ *verified* per channel. The replay guard, and nothing else.
+- `applied.json` — `{version, channel, at}`: the last build that **passed
+ a gate** on this box, forward or rollback. The `.prev` authority, and
+ nothing else. One record, not per channel: it is a fact about the box.
+- `result.json` — `{channel, version, promoted_at, outcome, reason, at}`
+ of the last run, keyed by the **request's** tuple (two promotes inside
+ one spread must not let a refusal of the first suppress the second).
+ What the agent publishes and §4 step 4 reads.
+
+Every file is written temp-then-`fsync`-then-`rename`-then-**`fsync` of
+the directory**, so the rename itself is durable before the next step
+depends on it; a leftover `*.tmp` at start is deleted unread.
+`carlos.prev` is made the same way (`carlos.prev.tmp`, fsync, rename,
+directory fsync), so a `.prev` is either whole or absent.
+
+`/opt/carlos/carlos` is installed **`root:root 0755`** from here on, not
+`carlos:carlos` as the script did. Every root oneshot on the box execs
+that file; the sandboxed user has no reason to own it.
+
+**Every run begins with recovery.** If `journal.json` exists, a previous
+run was cut off (SIGKILL, OOM, the timeout, a reboot). Which way it goes
+depends on one thing: whether the gate had passed.
+
+- **Phase `gated`** (step 10 passed, steps 11–12 not finished): the box
+ is on the new build and proved it. Recovery goes **forward**: finish
+ steps 11–12. Undoing a roll that passed its gate because the process
+ that recorded it was killed would be a rollback with no reason.
+- **Any earlier phase**: recovery goes **back** — an apply that did not
+ reach its gate is not one to trust, and `.prev` passed one. It is
+ **files only**: stop the quiesce set of step 7 (an operator or the
+ timer may have started a pass since); restore `carlos.prev` over
+ `carlos` (keeping `.prev`); `disable` and delete `units_added`; restore
+ `units_removed` and the rest of the previous unit set from
+ `staging/prev/` or, if `staging/` is gone, from a fresh `carlos.prev
+ bootstrap -root`; sysusers, tmpfiles, `daemon-reload`; `enable` (not
+ start) the previous plan's units so they come up at boot. Then, only if
+ `carlos-edge` has started at least once this boot
+ (`ActiveEnterTimestampMonotonic != 0`): `reset-failed`, `restart
+ --no-block carlos-edge`, and `start` the previous plan's timers and
+ paths. At boot the edge is ordered after this unit and its own start
+ *is* the restart, and the enabled timers and paths start with the
+ target; a recovery that restarted and gated the edge from inside a
+ unit the edge waits on would wait on itself forever. **Only now** write
+ `result.json` `{outcome: "rolled-back", reason: "interrupted at
+ <phase>"}` and remove the journal: a kill before this point leaves the
+ journal in place and the next run recovers again, which is idempotent;
+ a kill after it leaves nothing to do.
+
+`--recover` ends here. A path-triggered run continues below if there is
+also a request.
+
+Then the run proper. Failures in steps 1–6 change nothing on the box and
+are `refused` (or `deferred`, for a store error); from step 7 on, any
+failure goes to step 13.
+
+1. **Read the request.** One fixed path, `/data/system/router/requested.json`,
+ reached by opening `/data`, then each component with
+ `O_DIRECTORY|O_NOFOLLOW`, then the file with `O_NOFOLLOW`; must be a
+ regular file under 4 KiB. It yields `{version, promoted_at, noop}` and
+ nothing root will act on without checking. `version` must match
+ `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`, or the run is `refused` — this is
+ what makes the cache path derivable and un-traversable.
+2. **The channel is root's.** `CARLOS_SYSTEM_CHANNEL` from the unit's own
+ `EnvironmentFile`, default `stable`. The request does not carry one and
+ would not be believed if it did: a signed canary pointer authorizes
+ canary boxes, not a stable box whose sandboxed user would like to be
+ one.
+3. **Fetch and verify the pointer from the bucket** with
+ `CARLOS_RELEASE_PUBKEY`. A store error is `deferred`. The request's
+ `version` and `promoted_at` must equal the pointer's `Current.Version`
+ and `PromotedAt`, or `refused` (a request from a spread that started
+ under an earlier promote). **Replay guard:** the pointer's `PromotedAt`
+ must not be *older* than `seen.json[channel]`; equal is allowed, so an
+ apply that was interrupted or refused can be retried for the same
+ promote. Then advance `seen.json[channel]`. A box with no entry for the
+ channel accepts the first pointer it sees — a fresh, hand-rolled or
+ channel-moved box has nothing to protect yet (§11).
+4. **A no-op request ends here:** `result.json` `{outcome: "noted"}`,
+ exit 0. Root has seen the promote; nothing was downloaded, nothing
+ changes.
+5. **Copy the candidate into `staging/`.** The path is *derived*:
+ `/data/system/router/<version>/carlos`, walked like step 1, regular
+ file only. Copy to `staging/carlos` as **0600**; **verify the copy's
+ sha** against the pointer's artifact for this platform; then `chmod
+ 0755` and run it: `staging/carlos version`, 30-second deadline, must
+ print `carlos <version>`. The lie detector from `update.go:350`, on a
+ file only root can reach, made executable only after it is known to
+ be what the key signed. Nothing under `/data` is read again.
+6. **Decide `.prev`, then stage both unit sets.** Run
+ `/opt/carlos/carlos version` (30 s deadline). If it equals
+ `applied.json.version`, or `applied.json` is absent and `.prev` is
+ absent (a hand-rolled box: the running binary passed the script's own
+ post-condition), the on-disk binary is known good: **journal
+ `backing-up`** and copy it to `carlos.prev`. Otherwise — after a
+ `rollback-failed`, when the disk holds a bad binary and `.prev` the
+ good one — leave `.prev` alone. Then stage units from **both
+ binaries**: `staging/carlos bootstrap -root staging/new -plan json`
+ and `carlos.prev bootstrap -root staging/prev -plan json` (120 s
+ deadline each; `-plan json` is new and prints `enabledUnits` and the
+ inert list as JSON, so apply arms what *that* binary's bootstrap would
+ arm). `units_added = new − prev`, `units_removed = prev − new`, both
+ into the journal. Staging from `.prev`, not from the on-disk file,
+ because after a `rollback-failed` those are different builds and a
+ rollback must restore the units of the binary it restores.
+7. **Journal `installing`**, then **quiesce**: `systemctl stop` the root
+ timers and paths *and their services* — `carlos-adopt.timer`,
+ `carlos-adopt.path`, `carlos-stage.path`, `carlos-restart.path`,
+ `carlos-adopt.service`, `carlos-config-sync.service`,
+ `carlos-stage.service`, `carlos-restart.service`. Stopping a timer does
+ not stop the pass it already started, and a root pass running the new
+ binary against the old edge and registry is the window this exists to
+ close. Those passes are idempotent; killing one costs a retry. Then
+ **install**: `rename` `staging/carlos` over `/opt/carlos/carlos`
+ (same filesystem, atomic). **Journal `installed`.**
+8. **Install units and configs**: `disable --now` and delete
+ `units_removed` (a unit the new version dropped must not stay armed
+ and exec a command the new binary no longer has); copy
+ `staging/new/etc/systemd/system/*` into place; then, in this order and
+ never the other, **sysusers** (`cp` + `systemd-sysusers`), then
+ **tmpfiles** (`cp` + `systemd-tmpfiles --create`), then
+ `daemon-reload`. The ordering is failure-mode row 6 of the
+ console-as-an-app spec, and the reason is in the script's comment:
+ tmpfiles silently drops a line whose group does not resolve, and then
+ `carlos-edge` (`ReadWritePaths=/run/carlos`, no `-`) can never start
+ after the next boot. Never full `carlos bootstrap`: it re-chowns
+ `/data` to 0750, which breaks static-user tenants on the Tito box (the
+ script's opening comment). And never copy the staging root's `data/` —
+ it contains a 0-byte `registry.db` (`bootstrap.go:310`). **Journal
+ `units`.**
+9. **Restart the edge**: `systemctl reset-failed carlos-edge` first —
+ always, because a unit that hit its start limit refuses `restart`
+ with "start request repeated too quickly", and the rollback in step 13
+ would be refused the same way — then `systemctl restart carlos-edge`.
+ The one tenant-visible step; on a healthy box it is a one-second blip.
+ **Journal `restarted`.**
+10. **The gate**, 20 seconds to pass and then a **15-second soak**:
+ - `carlos-edge` is `active`; across the soak `NRestarts` stays 0 (a
+ manual restart resets it, so it counts only crash restarts since
+ step 9) and `ActiveEnterTimestampMonotonic` does not move. A binary
+ that starts, answers, and dies a second later is caught here, not
+ recorded as a success.
+ - The edge accepts a TCP connection on its listener and answers a
+ request on loopback with *any* HTTP status. Not a tenant probe: a
+ box with no route yet must still pass.
+ - `/opt/carlos/carlos version` prints the target.
+ **Journal `gated`.** From here recovery finishes forward.
+11. **Arm**, now that the box is on the new build and the gate has said
+ so: run `systemctlPlan(new plan's enabledUnits)` verbatim — enable
+ all, `start` timers and paths — which re-arms what step 7 stopped and
+ arms this spec's own path unit for the next roll. Not before the
+ gate: a rolled-back box is armed once, on the previous plan.
+12. **Record.** `applied.json` `{version, channel, at}`, `result.json`
+ `{outcome: "applied"}`, remove the journal, remove `staging/`. Done.
+ The agent deletes its `/data` cache when it sees the outcome.
+13. **Roll back.** `rename` `carlos.prev` to `carlos` keeping a copy as
+ `.prev`; `disable --now` and delete `units_added`; restore
+ `staging/prev/etc/systemd/system/*` (which brings `units_removed`
+ back); sysusers, tmpfiles, `daemon-reload`; `reset-failed`, restart
+ the edge, run the gate; then `systemctlPlan(prev plan's
+ enabledUnits)`. `result.json` `{outcome: "rolled-back", reason}`,
+ `applied.json` unchanged (the `.prev` build is what passed a gate,
+ and it is what `applied.json` already names), journal removed, exit
+ non-zero so systemd's journal shows it. If the rollback fails *its*
+ gate: `outcome: "rollback-failed"`, journal removed, exit non-zero —
+ `applied.json` still names the `.prev` build, so the next run's step
+ 6 leaves `.prev` alone. At every exit the installed binary and the
+ installed units came from the same build, *including* exits by kill,
+ timeout or reboot, because the next run recovers first and
+ `OnFailure=` makes sure there is a next run.
+
+The agent, whichever binary it now is, reads `result.json` on its next
+heartbeat and publishes it as **`BoxMetrics.System{Channel, Version,
+Outcome, Reason, At}`**, which is what `carlos system status` shows. A
+rolled-back box is therefore visible fleet-wide within five minutes, with
+the reason, and (§4 step 4) will not try that pointer again.
+
+---
+
+## 7. The first roll, and compatibility
+
+The first binary that contains this spec reaches a fleet by the old road:
+`update-platform-binary.sh`, one last time per box. That run must **arm
+`carlos-system-update.path` and enable `carlos-system-recover.service`**,
+or the first promote does nothing anywhere: a staged bootstrap runs no
+`systemctl` (`bootstrap.go:327`), and the path unit cannot arm itself
+before it works. Two belts:
+
+1. The script (pinfra, and Tito's copy in `tito/carlos-infrastructure`)
+ gains the two lines, guarded on the files existing, the way
+ `carlos-restart.path` was added.
+2. The root hourly pass (`RunAdopt`, from `carlos-adopt.timer`) gains a
+ **new, non-fatal first step**, placed before its registry and pubkey
+ `log.Fatalf` gates: if the embedded system-update units are missing
+ from `/etc/systemd/system`, or the path unit is inactive, or the
+ recover unit is not enabled, install and arm them via `systemctlPlan`.
+ New code — `RunAdopt`'s existing `capabilityPass` converges tenant
+ `carlos-app@<host>.service.d` drop-ins through `caps.Converge` and
+ installs no platform unit. So a box the script forgot is armed within
+ the hour, and the first promote is not lost: the leg re-touches its
+ marker every minute until root answers (§4), and `PathChanged` sees
+ the next touch after arming.
+
+From then on the script is never needed, because that binary contains the
+leg, the units and the apply. There is no flag day and no dual-running: an
+old agent does not read `control/system/`, so writing a pointer into a
+deployment whose boxes are old does nothing until they are rolled by hand
+once.
+
+Within one deployment, canary and stable boxes run different binaries by
+design, for as long as the operator leaves them that way. Nothing in the
+bucket schema they share changes with this spec (the pointer is a new key;
+`status/boxes` gains three optional fields), so a two-version fleet is
+exactly as safe as it is today after a partial script run.
+
+`carlos update` (the workstation CLI) is untouched except for the factored
+fetch-and-verify. Its header sentence — *a self-updating box binary would
+fight that machinery* — becomes true in the other direction: the box
+machinery is now the sanctioned one, and `runUpdate`'s refusal to run on a
+box stays.
+
+---
+
+## 8. Third-party deployments
+
+Tito's operator, on a laptop with a Tito console session, as an owner of
+Tito's ops account:
+
+```
+carlos system update --console https://console.tito.example --channel canary
+carlos system status --console https://console.tito.example
+carlos system update --console https://console.tito.example --channel stable
+```
+
+The Tito console fetches the public release from GitHub, verifies the
+vendor's signature, mirrors it into Tito's bucket, signs the pointer with
+Tito's key. Tito's boxes, behind Tito's store door, fetch from Tito's
+bucket. At no point does a CARLOS developer hold anything of Tito's, and
+at no point does a Tito box reach anything but Tito's console. What the
+vendor ships is a signed public release and nothing else, which is what
+#100 asked for.
+
+Egress: the **console** needs to reach `apt.carlosframework.com` and
+`github.com`. Boxes need nothing new. A deployment whose console cannot
+reach GitHub uses `--file`.
+
+---
+
+## 9. What this does NOT do
+
+- **Split the process.** `router` is the one binary. Updating the edge
+ without restarting the activator is not possible until they are separate
+ units, and that is not this spec.
+- **Order boxes within a channel** beyond the spread jitter. Two channels
+ are the ordering primitive in v1.
+- **Auto-promote canary to stable**, or bake-time enforcement. The
+ ladder is advisory.
+- **GC `bootstrap/router/`.** Old versions stay; they are ~25 MiB each and
+ the rollback verb needs them. A later slice can keep `History`'s depth.
+- **A console page.** `carlos system status` is the read surface in v1;
+ the fleet page's box rows already show `Release`, and a target column
+ there is a small follow-up.
+- **Scoped-grant signing** for platform pointers.
+- **Update the console** — it is an app.
+- **Stream the store door's GET.** It holds the object in memory today
+ for `bootstrap/carlos`; it will for `bootstrap/router/…`. Named in §11.
+- **Durable update jobs.** A console restart mid-promote loses the job;
+ re-running is safe.
+- **Any change to `carlos bootstrap` for a fresh box** beyond `-plan
+ json` and the three new units in `enabledUnits`. New boxes still
+ bootstrap and get their first binary by hand (`infra/remote-host.md`);
+ from the first heartbeat on they are on `stable` and roll with the
+ fleet.
+
+---
+
+## 10. What this retires
+
+- `update-platform-binary.sh` and the per-deployment roll runbooks, after
+ each deployment has been hand-rolled once onto a binary that carries
+ this spec. The script's sysusers/tmpfiles guard for *pre-#104* binaries
+ is not carried: nothing that old is rolled through this path.
+- The exact `bootstrap/carlos` grant in `aclRead`, and the key itself, one
+ release after the script is gone. Until then it is harmless.
+- The vendor needing SSM on a third-party deployment for a routine roll.
+- The "blocked on deploy scope" state this spec was born from: a merged
+ `main` becomes a release, and a release becomes a fleet with one
+ command per channel.
+
+---
+
+## 11. Proof, and live checks owed
+
+**Unit-level, in this repo:**
+
+- Pointer sign/verify round trip; scoped grant refused; wrong component
+ or channel refused; rollback pointer carries a new `PromotedAt`. The
+ door: both new shapes granted, both traversal siblings refused,
+ `bootstrap/carlos.sig` still refused, listing under either prefix still
+ refused, PUT under either still refused. `Store.Open` on all four
+ implementations, with the limit+1 read telling exact-limit from over.
+- The leg against a `DirStore`: no pointer → no request; pointer for another
+ platform → no request; version equal, pointer not newer than `seen` →
+ nothing; version equal, pointer newer → no-op request and no fetch;
+ pointer older than `seen` → nothing; sha mismatch → no request and file
+ deleted; oversized object → cut at the limit, no request; a
+ `result.json` naming this tuple as any terminal outcome → no request
+ and cache deleted; `deferred` → retried; a good pointer →
+ `requested.json` with no channel/key/path and the marker; the marker
+ re-touched every minute until a result names the tuple; the spread →
+ `tick` fires after the deadline and not before, with no store read in
+ between.
+- `carlos system apply` against a fake root and a fake `systemctl` that
+ records every call in order: `lock` held → path-triggered run exits 0
+ untouched, `--recover` waits; request read refuses a symlinked parent,
+ a symlinked file, an oversized file, a version that fails the pattern;
+ channel taken from the environment, not the request (a request under a
+ canary pointer on a stable box → `refused`); a stale tuple → `refused`
+ recorded against the *request's* tuple; replay: older → `refused`,
+ equal → proceeds, `seen` advanced only after verification; no-op →
+ `noted`, no `staging/`; the candidate copied 0600, sha-checked, chmod
+ after; a swap of the cache file after the copy → no effect; `version`
+ over its deadline → `refused`, `staging/` removed; `.prev` refreshed
+ when disk `version` equals `applied.json`, on a fresh box, and **not**
+ after `rollback-failed`; `staging/prev` from `.prev`, not from disk;
+ `units_added`/`units_removed` computed and journaled; quiesce stops
+ the services too, before the rename; `units_removed` disabled and
+ deleted forward; sysusers before tmpfiles by *call order*;
+ `reset-failed` before every restart; **no `start` of any `.service` in
+ any run** (the self-start deadlock); arming after the gate, forward and
+ back, from the right plan; a gate that passes and then fails in the
+ soak → rolled back; every journal phase before `gated` → recovery rolls
+ back files only, restores `units_removed`, restarts the edge only when
+ it has started this boot, and removes the journal last; phase `gated`
+ → recovery finishes forward and `applied.json` names the candidate; the exec shim picks `.prev` when a journal is open;
+ `rollback-failed` leaves `applied.json` and `.prev` alone; every write
+ is temp-fsync-rename and a leftover `.tmp` is discarded.
+- `carlos system update` against a stub release host: non-owner or service
+ credential → 403 before any fetch; bad sums signature refused; asset sha
+ mismatch refused; mirror skip when present and equal; mirror refused
+ when present and different; pointer CAS conflict retried; serial bumped
+ last; upload without `--platform` refused; job polled to completion;
+ job lost on restart reported, re-run idempotent.
+
+**Live, on the dev deployment first, then flagship canary:**
+
+1. Hand-roll the dev box with the script onto a build carrying this spec,
+ and confirm afterwards: `carlos-system-update.path` active,
+ `carlos-system-recover.service` enabled. That is the last script run
+ for that box.
+2. `carlos system update --file <next build> --version <sha> --channel
+ canary` with the dev box on canary. Watch `status` show the new
+ `Release`. Re-promote the same version: `status` shows `noted`, no
+ download in the edge log.
+3. Promote a deliberately broken binary (one that exits at start) the same
+ way. Confirm the edge went start-limit-hit and came back on `.prev`
+ regardless, `status` shows `rolled-back` with the reason, and a second
+ serial bump does not re-attempt it.
+4. Promote a binary that starts and exits after five seconds. Confirm the
+ soak catches it.
+5. Kill the oneshot between `installed` and `restarted` (a `sleep` build
+ with a `kill -9`). Confirm `OnFailure=` recovers to `.prev` without a
+ touch. Then reboot the box in the same state and confirm the boot unit
+ does, and that the edge starts after it without a hang.
+6. Move the box from canary to stable in `host.env`. Confirm a stable
+ pointer older than the canary one is applied, not refused.
+7. `carlos system rollback --channel canary`. Confirm a forward roll to the
+ previous version through the normal path, artifacts and all.
+8. On flagship: the console box on canary; promote a real tag; then stable
+ with the default spread; watch the tenant hostnames answer 200
+ throughout (the plan doc's rule at `console-as-an-app.md:1910`, now
+ observed rather than performed).
+9. Tito: Tito's operator runs §8 against a Tito console; the CARLOS side
+ watches nothing and touches nothing.
+
+**Known gaps to name so they are not discovered:**
+
+- The gate's loopback probe proves the process serves HTTP, not that a
+ tenant's route works. A binary whose routing is broken but whose
+ listener is fine passes the gate; `status` and the tenant's own
+ monitoring are the backstop, as they are today.
+- The replay guard has no baseline on a box that has never seen a pointer
+ for its channel. A bucket writer without the key can put an old signed
+ pointer in front of such a box once. That is the exposure app channel
+ pointers already have on every box, and the same actor could point
+ `control/serial` at nothing at all; the guard narrows it, it does not
+ close it.
+- Recovery depends on `carlos.prev` being able to run `system apply
+ --recover`. `.prev` passed a gate on this box, so the case is a `.prev`
+ that has since been damaged on disk — a box that was broken before this
+ roll, and is now a hand job.
+- The store door's GET holds the artifact in memory inside the console
+ box's edge (`MemoryHigh=70%`). Nine fleet boxes fetching 25 MiB inside
+ the same spread is not a problem; ninety would be, and streaming the
+ door's GET is the fix when that day comes.
+- `Spread` jitters starts, it does not serialize them; two boxes can still
+ restart in the same second. With nine boxes and a five-minute spread the
+ odds are small and the cost is two one-second blips.
+- A box that is asleep or dark during the promote rolls when it next
+ ticks. `status` shows it on the old version until then, which is the
+ truth.
+
+---
+
+## 12. Approaches not taken
+
+**Boxes fetch from the public release host directly.** No mirroring, no
+console egress. But every box then needs egress to GitHub, which a fleet
+box behind the store door may not have — it excludes exactly the
+deployment shape #100 exists for, and leaves the door's `bootstrap/` grant
+unused.
+
+**The platform as a reserved app (`ops/platform`).** `carlos promote
+--app platform` would just work and the console would show it. It is the
+shape the console-as-an-app spec rejected: the substrate has no route, no
+upstream, no instance, and every app assumption — health probe,
+hibernation, the adoption pass moving a `live/<host>` symlink — has to be
+special-cased for the one "app" that is not one. Cheap on day one and
+expensive on every day after.
+
+---
+
+## 13. Review findings, and where each landed
+
+Two rounds, two independent readers each, checking claims against the
+code. The table is so a reader can see what the drafts got wrong and how,
+rather than only that the text is now different.
+
+**Round one** (both readers unless noted):
+
+| Finding | Landed in |
+|---|---|
+| Root hashed and exec'd a file the sandboxed user owns — TOCTOU to root | §2, §6 steps 1, 5 |
+| No authorization rule; `requireDeployAPISession` grants nothing | §5 |
+| Door-backed boxes cannot read `control/system/` — silent never-update on the Tito shape | §3 |
+| Interrupted apply strands the box; on-disk `Build` equality then says "done" forever | §6 journal, recovery-first, boot unit, `OnFailure=` |
+| Rollback restored the binary but not the unit set | §6 steps 6, 8, 13 |
+| Edge start limit (default 5/10 s) refuses the rollback's restart | §1, §6 step 9 |
+| Gate had a deadline but no observation window | §6 step 10 |
+| `History` of version strings cannot reconstruct artifacts for rollback | §3 `SystemRelease` |
+| Root units exec the new binary against the old edge during the swap | §6 steps 7, 11 |
+| Generic "arm every timer" contradicts `enabledUnits` | §6 `-plan json`, `systemctlPlan` |
+| First-roll arming was circular | §7 |
+| `.prev` overwritten by a later run after `rollback-failed` | §6 step 6 |
+| Replay watermark missed no-op promotes; blocked canary→stable moves | §4 step 3, §6 `seen.json` |
+| Spread had nothing to run it between hourly passes | §4 `observe`/`tick` |
+| No exec deadlines; oneshot could hang before the gate | §4 units, §6 steps 5–6 |
+| 32 MiB cap is on PUT, not GET | §3 `Store.Open`, §4 step 6 |
+| Versions not immutable (Codex) | §3, §5 step 3 |
+| Console cannot know channel membership or fleet-prefixed heartbeats (Codex) | §5 |
+| Suppression covered only `rolled-back` (Fable) | §4 step 4 |
+| Store error during the console box's own roll read as a refusal (Fable) | §4 step 8, §6 `deferred` |
+| Binary owned `carlos:carlos` for no reason (Fable) | §6 |
+
+**Round two**, on the fixes above:
+
+| Finding | Landed in |
+|---|---|
+| `enable --now` on the plan starts the update oneshot from inside itself, and the recover unit mid-apply (both) | §4, §6 step 11: `systemctlPlan` verbatim, never starts a service; `lock` |
+| Boot recovery ordered before the edge restarts and gates the edge, waiting on itself (both) | §6 recovery: files only, restart only if the edge has started this boot |
+| One watermark doing replay guard *and* `.prev` authority breaks both (both) | §6 `seen.json` vs `applied.json` |
+| Recovery execs the candidate after the rename (both) | §4 exec shim prefers `.prev` under an open journal |
+| Stopping timers does not stop running passes; re-arm before restart reopened the window (both) | §6 steps 7, 11 |
+| `enable --now` would start a start-limited edge before `reset-failed` (Codex) | §6 step 11: no service is ever started by the plan |
+| `staging/prev` built from the on-disk binary, which after `rollback-failed` is not `.prev` (Codex) | §6 step 6 |
+| Crash durability of journal/`.prev` unspecified (Codex) | §6 temp-fsync-rename |
+| Request could name another channel's pointer (Codex) | §6 step 2 |
+| Request `path` still a root input; `O_NOFOLLOW` covers the last component only; copy before check exposed secrets in a world-readable dir (Fable) | §6 steps 1, 5: derived path, per-component walk, 0700 staging, 0600 until verified |
+| `LimitReader` cannot bound a `[]byte` `Get` (both) | §3 `Store.Open` |
+| Recover unit had no timeout (both) | §4 |
+| Inactive oneshots read as broken by the hourly belt and §11 (Codex) | §7, §11: path active, recover enabled |
+| Nothing re-touched the marker; `carlos adopt` does not converge platform units (both) | §4 `tick`, §7 belt 2 |
+| Rollback pointer's `PromotedAt` refused as a replay (both) | §3, §5 |
+| Forward install left removed units armed (Codex) | §6 steps 6, 8 |
+| No-op promote downloaded 25 MiB on every box (Fable) | §4 step 3, §6 step 4 |
+| `applied.json` shape undecided; canary→stable move skipped the `.prev` refresh (Fable) | §6: one record, compared to disk `version` |
+| `refused` recorded against the wrong tuple (Fable) | §6 `result.json` |
+| Update job lifecycle undecided (Fable) | §5, §9 |
+
+**Round three** (Codex closure check; 16 of 20 closed, four residuals):
+
+| Finding | Landed in |
+|---|---|
+| Interruption between `applied.json` and journal removal undid a roll that passed its gate | §6 step 10 `gated`; recovery goes forward from it |
+| Recovery removed its journal before issuing the restart; a kill in the gap left the candidate running with no journal | §6 recovery: result and journal removal last |
+| Recovery did not quiesce passes or restore the previous plan's armed state | §6 recovery: stops the quiesce set, `enable` at boot, `start` at runtime |
+| Directory fsync unspecified after renames | §6 durability |