carlos / platform Public

docs: app capabilities — README surface, and deviations recorded as rulings

README gains an "App capabilities" section in the "Host & process
monitoring" section's style: the manifest's capabilities shape, the
-capabilities ship flag and what it validates, the carlos route
-grant/-revoke/-hibernate/-backing surface, the pinned deploy order
(platform binaries first, capability-bearing releases second), and the
refuses-vs-leaves-alone distinction that is the whole fail-closed direction
in one paragraph. Understated on purpose — no console UI is claimed beyond
the read-only grants line this branch actually ships.

The design spec gains "Implementation note (2026-08-03)" callouts, in the
style §4c's existing note already set, recording places the build deviated
from the plan's original text or the plan left unresolved:

- where converge is armed (RunAdopt only, never the sandboxed in-process
  loops) and the accepted canary fast-loop window, now logged;
- caps.Plan.DeferRestart as the mechanism that keeps a version change to one
  restart cycle instead of two;
- a converge REFUSAL is deliberately not poisoned — only a bounced restart
  protects other hosts from a bad build, and a refusal is often host-shaped;
- host.capabilityPass announcing a drift repair even when no byte changed,
  because silently restarting a unit an operator just stopped to debug it
  would be the one surprising thing this pass does;
- dbs[0] taken AS DECLARED, not sorted — overruling the plan's own text,
  because declaration order is the app's only statement of which database is
  primary;
- the DBPath stamp's guard against overwriting an operator's value it does
  not recognise, which follows the operator's data copy rather than leading it.

§5b/§5c's `carlos route -backing unit <host>` examples are corrected to the
syntax the flag actually takes (`-host <host> -backing unit` — -host is
required, not positional), with a note confirming -backing and -grant/-revoke
are real, shipped CLI surface and not aspirational spec text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a64a34c9753726dadb4c4e983ab83eeb4e057de3 parent 0d4b2ef
2 files changed, +184 −16
  • README.md +89 −0
  • docs/superpowers/specs/2026-08-03-app-capabilities-design.md +95 −16
diff --git a/README.md b/README.md
index c21cef5ab..1717d32e4 100644
--- a/README.md
+++ b/README.md
@@ -417,3 +417,92 @@ for the full design.
The `days/` rollups are written from the first flush onward but nothing
renders them yet — they are the history a later trend view will read.
+
+## App capabilities
+
+A handful of apps need more from the platform than the `carlos-app@<host>`
+template gives every instance for free — a subcommand other than `serve`, a
+privileged port, the box's cloud instance role. Rather than hand-provision a
+bespoke unit per app, a release **declares** what it needs and the platform
+decides how — and whether — to provide it, from a closed vocabulary of seven
+names. See
+[`docs/superpowers/specs/2026-08-03-app-capabilities-design.md`](docs/superpowers/specs/2026-08-03-app-capabilities-design.md)
+for the full design; this section is the operator-facing summary.
+
+**The manifest shape** — an optional `capabilities` object on the release
+manifest, additive to schema 1 (a manifest with no `capabilities` key parses
+as "none", today's behaviour byte for byte):
+
+ "capabilities": {
+ "v": 1,
+ "command": "relay",
+ "daemon": true,
+ "children": true,
+ "runtime_dir": "keymail",
+ "ports": [ { "port": 25, "name": "mx" } ],
+ "data": { "replicate": "app" },
+ "host_role": true
+ }
+
+`command`, `daemon`, `children`, `runtime_dir` and `data` are self-service —
+they shape only the app's own sandbox and materialize from the manifest
+alone. `ports` and `host_role` are operator-consented: declaring one asks for
+it, but nothing materializes until an operator has granted it **on that
+specific box**, because the resource (a privileged port, the box's IAM role)
+is box-local, not fleet-wide.
+
+**Declaring a capability** rides `carlos ship -capabilities <file.json>`,
+where the file holds exactly the `capabilities` object above (not a whole
+manifest). The same strict decoder and validation gate ship and adopt both
+run: an unknown capability name or field, a bad charset, a reserved
+`runtime_dir`, or an out-of-range/reserved port is refused at the ship
+terminal, where a human is standing to read it — no per-capability flags,
+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 -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).
+
+**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
+*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.
+
+**What refuses vs what leaves alone** — the distinction that matters most:
+
+- An unreadable or unresolved staged manifest **leaves every drop-in exactly
+ as it is** and logs once per pass. A bucket hiccup must never strip `:25`
+ off a running mail server.
+- A manifest that actually resolves and no longer declares a capability (or
+ a grant that gets revoked) **removes** the generated drop-in.
+- A hand-edited drop-in — anything at the generated path without the
+ `# GENERATED by carlos adopt — do not hand-edit` marker on its first
+ line — is never overwritten and never deleted; it is reported and that
+ unit's half is skipped while the other half still converges.
+- An unknown capability name or an unsupported `"v"` refuses the *version on
+ that box*, not the whole fleet — the box keeps serving what it already
+ runs.
+
+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
+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 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 →