| index 38afcb3c4..6ca337068 100644 |
| --- a/docs/superpowers/specs/2026-08-03-app-capabilities-design.md |
| +++ b/docs/superpowers/specs/2026-08-03-app-capabilities-design.md |
| @@ -455,6 +455,52 @@ route it handles today — byte-identical behaviour — and uses `restart` for |
| `daemon` routes, so a daemon that crashed into a stopped state comes back on |
| the new version instead of being left down by a try-restart no-op. |
| |
| +**Implementation note (2026-08-03): where converge is armed, and the window |
| +that leaves open.** Converge is wired into `RunAdopt`'s `carlos-adopt.service` |
| +pass only — never into the in-process `adoptLoop` or the 2-3 second |
| +`canaryAdoptLoop`, both of which run inside the sandboxed `carlos-edge.service` |
| +(`ProtectSystem=strict`, `ReadWritePaths=/data /run/carlos`) and therefore |
| +cannot write `/etc/systemd/system` at all. Consequence, accepted rather than |
| +engineered around: a freshly promoted canary can run its new binary against |
| +the *previous* version's unit shape until the hourly root pass converges it. |
| +The fast loop makes the window visible rather than silent — it logs one line |
| +per version change per host, naming the route and the version, whenever it |
| +points a capability-declaring route live without converging its unit shape. |
| + |
| +**Implementation note (2026-08-03): one restart cycle, not two.** The |
| +version-change converge inside `adoptHost` runs *before* `PointLive`, which |
| +means the OLD binary is still live while the NEW version's drop-ins are |
| +written. `caps.Plan.DeferRestart` is what keeps that from cycling a daemon |
| +twice — once when converge would otherwise bring the (still-old) service up |
| +under the new unit shape, and again for the real restart onto the new binary. |
| +With it set, converge writes and enables but leaves the service down across |
| +the symlink swap, and the caller's own `caps.Restart` brings it up exactly |
| +once, already on the new binary. `host.capabilityPass` (the grant-drift pass) |
| +never sets it: there is no version change and no follow-up restart to defer |
| +to, so that pass restarts drift itself. |
| + |
| +**Implementation note (2026-08-03): a converge refusal does not poison the |
| +version.** `adoptHost`'s existing poison map exists to keep a build that |
| +failed its *restart* off every other host for the rest of the pass. A |
| +converge *refusal* — an unreadable staged manifest, a port another route's |
| +drop-in already claims, a `host_role` flip against a state directory in the |
| +wrong shape — is deliberately **not** added to that map: nothing was bounced |
| +(no symlink moved, no unit cycled), so there is no bad build to protect other |
| +hosts from, and the refusal is frequently host-shaped rather than |
| +build-shaped. Poisoning on one host's port conflict would freeze every other |
| +host on the box out of a version that is perfectly fine for them. |
| + |
| +**Implementation note (2026-08-03): the drift pass announces its own |
| +repairs.** `host.capabilityPass` (step 2 above) can bring a unit's *state* |
| +back in line — enabling a socket that was not enabled, starting a daemon that |
| +was stopped, restarting a socket that was inactive — without any drop-in byte |
| +changing, so none of it is covered by `Result.Changed`. Each such repair is |
| +logged on its own line, once per pass, specifically because it is the most |
| +surprising thing this pass can do: silently starting a unit an operator |
| +stopped ten minutes ago to debug it would turn an hourly maintenance pass |
| +into an adversary. Only a repair that *succeeded* is announced; a failed one |
| +is already reported by the ordinary error line. |
| + |
| ### 4e. Backing, and the rails around it |
| |
| Capabilities are honoured only for `Kind == "instance"`, `Backing == |
| @@ -538,7 +584,7 @@ Steps, per box: |
| 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 -backing unit <host>` (a narrow setter; a |
| + `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 |
| @@ -549,11 +595,11 @@ Steps, per box: |
| 4. Remove `/etc/systemd/system/console.service`. Confirm the per-unit |
| metrics table now lists `carlos-app@<host>.service`. |
| |
| -Rollback: stop and disable the templated unit, `carlos route -backing ""`, |
| -re-enable `console.service` (its data is untouched at `/data/console`). |
| -Console writes made after cutover are lost by rolling back — keep the window |
| -short, the same posture the keymail cutover plan takes about its fork |
| -window. |
| +Rollback: stop and disable the templated unit, |
| +`carlos route -host <host> -backing ""`, re-enable `console.service` (its |
| +data is untouched at `/data/console`). Console writes made after cutover are |
| +lost by rolling back — keep the window short, the same posture the keymail |
| +cutover plan takes about its fork window. |
| |
| ### 5c. keymail relay |
| |
| @@ -573,12 +619,13 @@ Steps, once the flag is merged and shipped: |
| 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: `port:25`, `host-role`. Operator: nft allowance for |
| - `carlos-keymail`. |
| +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 -backing unit keymail.dev` → `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`, |
| + → `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 |
| @@ -588,11 +635,22 @@ Steps, once the flag is merged and shipped: |
| 5. `status.keymail.dev` becomes (or stays) a `kind=service` proxy route to |
| the same upstream. |
| |
| -Rollback: stop the templated unit, `carlos route -backing ""`, re-enable |
| -`keymail-relay.service` — its data is still at `/opt/keymail/data` and its |
| -own embedded litestream resumes from local files. Mail accepted during the |
| -window exists only in the streamed lineage; short window, quiet hour, same |
| -caveat the cutover plan already carries. |
| +Rollback: stop the templated unit, `carlos route -host keymail.dev -backing ""`, |
| +re-enable `keymail-relay.service` — its data is still at `/opt/keymail/data` |
| +and its own embedded litestream resumes from local files. Mail accepted |
| +during the window exists only in the streamed lineage; short window, quiet |
| +hour, same caveat the cutover plan already carries. |
| + |
| +**Implementation note (2026-08-03): `-backing` and `-grant`/`-revoke` are |
| +real, and this is their exact syntax.** Both §5b and §5c above reference |
| +`carlos route`'s backing switch and its grant door; as built, both are |
| +single-route axes that require `-host` (never `-app`, and never combined |
| +with `-channel`/`-environment` in one invocation) — `carlos route -host |
| +<host> -backing unit|""` and `carlos route -host <host> (-grant|-revoke) |
| +<port:n|host-role>`. `-backing` is tri-state at the flag level (unset vs. |
| +`""` vs. `"unit"`, distinguished with `fs.Visit`, the same technique |
| +`-hibernate` uses) because `""` is itself a legal column value meaning "an |
| +exec child of the agent". |
| |
| ### 5d. What the infra repo loses |
| |
| @@ -640,6 +698,27 @@ instances, with no change at all to the monitoring code: |
| the route's `DBPath` at converge time — instead of depending on an |
| operator having typed `-db` at `carlos add`, which the current code |
| documents as "silently replicated by nobody". |
| + |
| + **Implementation note (2026-08-03): `dbs[0]` as declared, not sorted.** |
| + When `data.dbs` names more than one file, the stamp takes the **first |
| + entry in declaration order**, not the lexicographically first — overruling |
| + the plan's original "sort the names" text. An app writing |
| + `["console.db", "audit.db"]` means `console.db` is the database that |
| + matters; sorting first would have replicated `audit.db` instead, which is |
| + the platform silently picking a different database than the one the app |
| + named. Declaration order is the only place that statement lives. |
| + |
| + **Implementation note (2026-08-03): the stamp never overwrites an |
| + operator's value it does not recognise.** An existing, non-empty |
| + `db_path` that is not already under this box's own state directory for |
| + that host (i.e. not a path this same stamp could have written) is left |
| + alone and advised about, not overwritten. On a pre-migration route that |
| + value is the operator's `-db`, pointing at where the data lives *right |
| + now* (e.g. `/data/<host>.db`, the file the box-wide replicator is |
| + streaming today); overwriting it with the post-migration path before the |
| + operator's §5 data copy has happened would point litestream at a file |
| + that does not exist yet and silently stop replicating a live database. |
| + The stamp follows the copy — it never leads it. |
| - The backing-mismatch and external-socket advisories stop firing for these |
| two hosts, because the thing they were warning about is fixed. |
| - Restart on adopt is the platform's, in the documented promote → adopt → |