| 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 ""`, |