# `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 …]`**

Pops `History[0]` into `Current` — whole releases, so the previous
artifacts and hashes come back with the version — re-signs with
`PromotedAt` now, bumps the serial. The outgoing release is not pushed
back, so a second rollback goes one further back rather than undoing the
first. 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.
- A permanently failing recovery re-runs every minute (the agent re-touches
  the marker while its request is unanswered) and never writes
  `result.json`, so the fleet sees a stale result; that is the needs-a-human
  state, and it is fleet-invisible.

---

## 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 |
