carlos / platform Public

docs: the cutovers in the order the branch's own rails allow

Both migration checklists contradicted the code they describe. §5a told the
operator to stage grants as a prerequisite; grantRail refuses a grant on any
route that is not unit-backed, so the grant cannot precede the -backing
stamp — it follows it. §5c copied keymail's data AFTER `carlos adopt`, which
is F4's restore-comes-up-empty written down as a checklist: adopt is not a
drop-in renderer, it ENABLES AND STARTS a daemon route's service, so an
adopt against an empty state directory starts the app on empty data and it
comes up looking healthy.

Both cutovers are now: stop the bespoke unit -> copy the data -> -backing
unit -> -grant -> carlos adopt -> verify, with the reason on each step. The
data copy notes that converge's EnsureUser creates carlos-<app> during the
adopt, so the chown either needs an explicit useradd first or has to wait.
Both note that stamping -backing arms an HOURLY carlos-adopt.timer, not just
the operator's next hand-run pass. keymail gains the clear-hibernate step:
daemon + hibernate is refused, and left set it aborts the converge.

§2's grants block gets the real `-host <h> -grant <tok>` syntax instead of
positional args, plus the unit-backing requirement. §3b and the README name
the CONSOLE binary explicitly as part of "platform binaries first" — the
ship door is the console on flagship, so it is the binary that either
understands the capabilities key or silently drops it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell bb3dd4c2eb79ef152edb782173be0743386b4c3f parent 05fa2be
2 files changed, +174 −47
  • README.md +39 −16
  • docs/superpowers/specs/2026-08-03-app-capabilities-design.md +135 −31
diff --git a/README.md b/README.md
index 1717d32e4..0150b80cc 100644
--- a/README.md
+++ b/README.md
@@ -461,31 +461,53 @@ because a flag vocabulary would drift from `internal/caps`'s own.
**Granting a capability** is `carlos route`, per route, per box:
+ carlos route -host keymail.dev -backing unit # first: grants need it
carlos route -host keymail.dev -grant port:25
carlos route -host keymail.dev -grant host-role
carlos route -host keymail.dev -revoke port:25
carlos route -host keymail.dev -hibernate=false
- carlos route -host keymail.dev -backing unit
-
-`-grant`/`-revoke` require `-host` and never accept `-app` — a grant is per
-route per box by design, and a bulk grant across every route of an app is
-precisely the operation nobody should be able to type by accident. A
-declared-but-ungranted capability does not fail the app or fail adoption: the
-drop-in simply is not written, and the hourly adopt pass logs one line
-naming the route, the capability, and the exact grant command. `-backing
-unit` is the switch that moves a route from an agent-run exec child onto a
-`carlos-app@<host>` systemd instance — capabilities are honoured only for
-`Kind == "instance"` routes with `Backing == "unit"`; an exec-backed route
-that declares capabilities gets the same advisory treatment and materializes
-nothing until it is switched over (see the design's §5 migration steps).
+
+Each of `-backing`, `-grant`/`-revoke` and `-hibernate` is its own operation:
+they require `-host` (never `-app` — a bulk grant is precisely the operation
+nobody should be able to type by accident), and none of them combines with
+another, or with `-channel`/`-environment`, in one invocation.
+
+The order above is not cosmetic. **A grant requires unit backing**: a route
+that is not a `kind=instance`, `backing=unit` row has nothing for a grant to
+materialize into, so `-grant`/`-revoke` are refused there, and grants cannot
+be staged ahead of a migration. `-backing unit` is the switch that moves a
+route from an agent-run exec child onto a `carlos-app@<host>` systemd
+instance; an exec-backed route that declares capabilities materializes
+nothing and is advised once per pass until it is switched over (see the
+design's §5 migration steps). Stamping it arms the **hourly**
+`carlos-adopt.timer`, and `carlos adopt` for a `daemon` route enables and
+STARTS the service — so the data copy comes before the stamp, not after it.
+
+A declared-but-ungranted capability does not fail the app or fail adoption:
+the drop-in simply is not written, and the hourly adopt pass logs one line
+naming the route, the capability, and the exact grant command. The mirror
+line is there too — a token this box has granted that the live release no
+longer declares materializes nothing, and is named with the revoke command
+rather than left sitting in the registry looking like live authority.
**Deploy order is pinned**, same rule and reason as scoped signing keys: the
-platform binary that understands a capability name has to be on the box
+platform binaries that understand a capability name have to be on the box
*before* a release declaring it, never after. An older binary meeting a
manifest it does not understand refuses the version on that box — not
staged, not pointed at — rather than materialize a unit missing something
the app says it needs.
+**"Platform binaries" includes the console binary**, and on the flagship that
+is the one that matters: `carlos ship` in API mode posts through the console,
+so the console is what either understands the `capabilities` key or silently
+drops it as an unknown JSON field — storing a manifest that declares nothing,
+which on a box is the instruction to REMOVE the drop-ins. The ship response
+therefore echoes the capability block it stored, and `carlos ship` fails hard
+when it sent a block and gets no echo back ("this console does not understand
+capability declarations — update the console binary first, or ship without
+`-capabilities`"). Update the console before shipping a capability-bearing
+release.
+
**What refuses vs what leaves alone** — the distinction that matters most:
- An unreadable or unresolved staged manifest **leaves every drop-in exactly
@@ -503,6 +525,7 @@ the app says it needs.
Console gets, at most, a read-only line beside each route's other facts —
"granted: host-role, port:25" for a route with grants, nothing at all
-otherwise. There is no console UI for granting or revoking; grants stay a
-`carlos route` operation on the box, and nothing about the console's
+otherwise, and only on routes belonging to the account whose page it is.
+There is no console UI for granting or revoking; grants stay a `carlos route`
+operation on the box, and nothing about the console's
sign-in/session/CSRF/owner gates changes.
diff --git a/docs/superpowers/specs/2026-08-03-app-capabilities-design.md b/docs/superpowers/specs/2026-08-03-app-capabilities-design.md
index 6ca337068..5d0e14676 100644
--- a/docs/superpowers/specs/2026-08-03-app-capabilities-design.md
+++ b/docs/superpowers/specs/2026-08-03-app-capabilities-design.md
@@ -126,21 +126,37 @@ Grants are a new registry column, per route, per box — the operator's door
is `carlos route`, which already carries safety rails and narrow setters:
```
-carlos route -grant port:25 keymail.dev
-carlos route -grant host-role platform.carlosframework.com
-carlos route -revoke port:25 keymail.dev
+carlos route -host keymail.dev -grant port:25
+carlos route -host platform.carlosframework.com -grant host-role
+carlos route -host keymail.dev -revoke port:25
```
+The host is a `-host` FLAG, not a positional argument (as built; corrected
+here 2026-08-03). `-grant`/`-revoke` never accept `-app`: a grant is per
+route per box by design, and a bulk grant across every route of an app is
+precisely the operation nobody should be able to type by accident.
+
Per box, not per fleet, because the resource is box-local: `:25` can be held
by exactly one process on one machine, and the IMDS gate is that box's
firewall. Two boxes running the same app is two grants, deliberately.
+**A grant REQUIRES unit backing.** `grantRail` (cli.go) refuses `-grant` and
+`-revoke` alike on any route that is not a `kind=instance`, `backing=unit`
+row — a grant there would materialize nothing, and a "revoke" there would be
+theatre. That single fact fixes the order of every migration in §5: the
+`-backing unit` stamp comes BEFORE the grant, never after, and grants cannot
+be staged ahead of the cutover.
+
A declared-but-ungranted capability does not fail the app and does not fail
adoption: the drop-in simply is not written, and the pass logs one line per
pass naming the route, the capability, and the exact `carlos route -grant`
command. keymail with no `port:25` grant serves HTTP and does not bind the
MX — senders retry, which is what SMTP is for — instead of refusing to start
-and taking the web UI down with it.
+and taking the web UI down with it. The mirror line exists too: a token this
+box has GRANTED that the live release does not declare materializes nothing,
+and is named once per pass with the revoke command — a grant that outlived
+the release that asked for it is a registry row that reads like authority
+somebody is using.
## 3. Wire and storage shapes
@@ -232,6 +248,23 @@ releases second — the same rule and the same reason as scoped signing keys
(`signedWrapper.Grant`: an old binary meeting a new document must fail in
the direction that changes nothing).
+"Platform binaries" includes **the CONSOLE binary, explicitly**, and on the
+flagship that is the one that matters most: the ship door is the console
+(`carlos ship` in API mode POSTs to `handleAPIShip`), so the console is the
+binary that either understands the `capabilities` key or silently drops it.
+It is an unknown JSON key to an older console, Go's decoder ignores unknown
+keys, and the release then lands in the bucket with a manifest declaring
+NOTHING — which on a box is not "no change" but the REMOVAL instruction: the
+converge pass deletes the drop-ins and `:25` comes off a running mail server
+because a binary somewhere was old.
+
+Made loud rather than left to discipline (2026-08-03 review): the ship
+response echoes the capability block it actually stored, and `carlos ship`
+fails hard when it sent a block and the echo is missing or different —
+"this console does not understand capability declarations — update the
+console binary first, or ship without `-capabilities`". The failure happens
+at the terminal, before a promote, instead of an hour later on a box.
+
### 3c. On-box storage
- **Staged manifest**: `ensureStaged` writes the manifest it already fetched
@@ -543,11 +576,30 @@ a copy rather than a move, so rollback has a body to return to.
`carlos bootstrap -root /tmp/carlos-unitstage`). Deploy order is not
optional: an older binary meeting a capability-bearing manifest refuses
the version (§3b).
-2. Grants staged on the route (`carlos route -grant …`).
+2. **The console binary too**, before any capability-bearing release is
+ shipped — on the flagship the ship door IS the console (§3b). `carlos
+ ship -capabilities` against an un-upgraded console refuses at the
+ terminal now rather than storing a declaration-free manifest, but the
+ refusal is a stop sign, not a substitute for doing this first.
3. For `host_role`: the operator's nft allowlist entry for the new
`carlos-<app>` uid, and the same uid added to `harden-host.sh` so a
hardening re-run does not strip it (F1's second half).
+**Grants are NOT a prerequisite and cannot be staged early** (corrected
+2026-08-03; the previous version of this list said to stage them, and the
+code refuses it). `grantRail` refuses `-grant`/`-revoke` on any route that
+is not `kind=instance` + `backing=unit` — before the cutover these routes
+are exec-backed, so the grant is refused, and if it were not it would
+materialize nothing. The grant belongs AFTER the `-backing unit` stamp, in
+the cutover itself. Both checklists below are in that order.
+
+**Do not stamp `-backing unit` until the box is ready for it.**
+`carlos-adopt.timer` fires **hourly** (`OnUnitActiveSec=1h`, plus a
+randomized delay), so the stamp does not wait for an operator to run
+`carlos adopt` by hand: it arms an unattended converge that may land minutes
+later. The stamp is the point of no return in each checklist below, which is
+why the data copy sits above it.
+
### 5b. console
console is the thing that ships and promotes, so the chicken-and-egg
@@ -581,17 +633,41 @@ Steps, per box:
to `canary/console`. The route is still exec-backed, so the box stages
the binary, changes nothing about the running unit, and logs the
capability/backing advisory. This is the reversible dress rehearsal.
-3. Cutover (one operator session, seconds):
- `systemctl disable --now console.service` → copy `console.db`,
- `-wal`, `-shm` to `/var/lib/carlos-app/<host>/`, owned `carlos-console`,
- `0700` → `carlos route -host <host> -backing unit` (a narrow setter; a
- `carlos add` re-upsert is the wrong tool — it resets the channel, the
- issue #14 lesson) → `carlos adopt` (writes the drop-ins, creates the
- user, enables and starts `carlos-app@<host>.service`) → verify: unit
- active, `/healthz`-equivalent through the edge, sign-in round trip, and
- `systemctl show -p RestrictSUIDSGID carlos-app@<host>.service` reports
- `RestrictSUIDSGID=yes` (confirms the static-user drop-in restored what
- dropping `DynamicUser` took away).
+3. Cutover (one operator session, seconds), **in this order**:
+
+ 1. `systemctl disable --now console.service` — the bespoke unit stops
+ first, because a single-writer SQLite app cannot have two processes on
+ one database and the next step copies it.
+ 2. Copy `console.db`, `-wal`, `-shm` to `/var/lib/carlos-app/<host>/`,
+ `0700`, owned `carlos-console`. **The user has to exist to be chowned
+ to**: converge's `EnsureUser` creates `carlos-<app>` — but only during
+ a converge, and only when `host_role` is granted, both of which happen
+ below. Either `useradd --system carlos-console` explicitly here, or do
+ the copy now and the `chown -R carlos-console:carlos-console` after
+ the first adopt. Not "chown to a name that does not resolve yet".
+ 3. `carlos route -host <host> -backing unit` — a narrow setter; a
+ `carlos add` re-upsert is the wrong tool, it resets the channel (the
+ issue #14 lesson). This is the point of no return: `carlos-adopt.timer`
+ is hourly, so from here a converge can land without anyone typing
+ anything.
+ 4. `carlos route -host <host> -grant host-role` — **after** the stamp,
+ never before: a grant onto a route that is not unit-backed is refused
+ by `grantRail`, and would materialize nothing if it were not (§2).
+ Ungranted, the unit keeps `DynamicUser` and cannot use the box's
+ instance role.
+ 5. `carlos adopt` — this writes the drop-ins, creates the user, and
+ **enables and starts `carlos-app@<host>.service`**. It is not a
+ drop-in renderer: for a `daemon` route it brings the service up, right
+ then. That is precisely why the data copy is step 2 and not step 5 —
+ an adopt run against an empty state directory starts console on an
+ empty database and the app comes up looking healthy with nothing in
+ it. (F4's restore-comes-up-empty, in the one order that reproduces
+ it.)
+ 6. Verify: unit active, `/healthz`-equivalent through the edge, sign-in
+ round trip, the row counts you expect in the copied database, and
+ `systemctl show -p RestrictSUIDSGID carlos-app@<host>.service` reports
+ `RestrictSUIDSGID=yes` (confirms the static-user drop-in restored what
+ dropping `DynamicUser` took away).
4. Remove `/etc/systemd/system/console.service`. Confirm the per-unit
metrics table now lists `carlos-app@<host>.service`.
@@ -618,21 +694,49 @@ Steps, once the flag is merged and shipped:
`carlos secrets set` for the Google client secret.
2. Ship a release declaring `command: relay`, `daemon`, `children`,
`runtime_dir: keymail`, `ports: [25]`, `data: {replicate: "app"}`,
- `host_role`. Promote on the canary channel. Nothing changes yet.
-3. Grants: `carlos route -host keymail.dev -grant port:25`,
- `carlos route -host keymail.dev -grant host-role`. Operator: nft
- allowance for `carlos-keymail`.
-4. Cutover: `systemctl disable --now keymail-relay.service` (releases `:25`)
- → `carlos route -host keymail.dev -backing unit` → `carlos adopt` (the
- socket unit binds `:25` immediately — inbound connections queue rather
- than bounce) → copy `/opt/keymail/data` to `/var/lib/carlos-app/keymail.dev`,
- owned `carlos-keymail`, `0700` → start. Verify **before declaring
- success**: the `backup: restored …` log lines, a federation lookup 200,
- the instance children present, `/run/keymail/*.sock` populated, and
- `systemctl show -p RestrictSUIDSGID carlos-app@keymail.dev.service`
- reports `RestrictSUIDSGID=yes`. F4's restore-succeeds-empty failure mode
- makes "the unit is active" an insufficient check.
-5. `status.keymail.dev` becomes (or stays) a `kind=service` proxy route to
+ `host_role`. Promote on the canary channel. Nothing changes yet — the
+ route is still exec-backed, so the box stages the binary and logs the
+ capability/backing advisory.
+3. Operator prerequisites that are not `carlos` commands: the nft allowance
+ for `carlos-keymail`, and the same uid in `harden-host.sh`. (The grants
+ themselves are NOT stageable here — see §5a: `grantRail` refuses a grant
+ on a route that is not yet unit-backed.)
+4. **Clear hibernation first if the route has it**: `carlos route -host
+ keymail.dev -hibernate=false`. `daemon` + `hibernate` is a refusal, in
+ both doors and in the converge pass — the platform is being asked to keep
+ a process up and to stop it when idle — and left set it aborts the
+ converge with nothing written and no unit state changed. It is also its
+ own command: `-hibernate` and `-backing` are mutually exclusive in one
+ invocation.
+5. Cutover, **in this order**:
+
+ 1. `systemctl disable --now keymail-relay.service` — releases `:25`, and
+ stops the single writer before its data is copied.
+ 2. Copy `/opt/keymail/data` to `/var/lib/carlos-app/keymail.dev`, `0700`,
+ owned `carlos-keymail` — with the same caveat as §5b step 3.2: the
+ user is created by converge's `EnsureUser` during the adopt below, so
+ either `useradd --system carlos-keymail` explicitly now, or chown
+ after the first adopt.
+ 3. `carlos route -host keymail.dev -backing unit`. Point of no return:
+ the adopt timer is hourly.
+ 4. `carlos route -host keymail.dev -grant port:25` and `carlos route
+ -host keymail.dev -grant host-role` — after the stamp, because
+ `grantRail` refuses them before it (§2). Ungranted, `:25` is simply
+ not bound and the unit keeps `DynamicUser`.
+ 5. `carlos adopt` — writes the drop-ins, creates the user, enables the
+ socket unit (which binds `:25` immediately, so inbound connections
+ queue rather than bounce) and **enables and starts**
+ `carlos-app@keymail.dev.service`. The data copy is step 2 and not step
+ 5 for exactly this reason: adopt starts the relay, and a relay started
+ against an empty state directory is F4's restore-succeeds-empty with
+ the platform holding the door open.
+ 6. Verify **before declaring success**: the `backup: restored …` log
+ lines, a federation lookup 200, the instance children present,
+ `/run/keymail/*.sock` populated, and `systemctl show -p
+ RestrictSUIDSGID carlos-app@keymail.dev.service` reports
+ `RestrictSUIDSGID=yes`. "The unit is active" is not a sufficient
+ check — that is the whole lesson of F4.
+6. `status.keymail.dev` becomes (or stays) a `kind=service` proxy route to
the same upstream.
Rollback: stop the templated unit, `carlos route -host keymail.dev -backing ""`,