carlos / platform Public

spec: app capabilities — a closed vocabulary, provided by the platform

Two production apps run as bespoke hand-provisioned units the infra repo
does not even have the text of. Seven capability names — command, daemon,
children, data, runtime_dir self-service; ports and host_role
operator-granted — materialize as vetted drop-ins on the one carlos-app@
template, declared in the release manifest so a rollback rolls back the
unit shape with the binary. Migration paths for console.service and
keymail-relay.service; monitoring then attributes both with zero special
cases.

Directive (Paul): avoid special cases for CARLOS apps — app capabilities
should be provided by the platform.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5086caeed3f93cd1a5e9d48f5b3223460c701dc7 parent b8e930b
1 file changed, +734 −0
  • docs/superpowers/specs/2026-08-03-app-capabilities-design.md +734 −0
diff --git a/docs/superpowers/specs/2026-08-03-app-capabilities-design.md b/docs/superpowers/specs/2026-08-03-app-capabilities-design.md
new file mode 100644
index 000000000..484c2cedc
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-03-app-capabilities-design.md
@@ -0,0 +1,734 @@
+# App capabilities — design
+
+Date: 2026-08-03. Directive (Paul, verbatim): *"We want to start avoiding
+special cases for CARLOS apps. App capabilities should be provided by the
+platform."*
+
+Two production apps run as bespoke, hand-provisioned systemd units instead
+of platform-managed `carlos-app@<host>.service` instances:
+
+- **console** (`console.service`, both boxes) — the platform console. Long
+ running, owns `console.db`, reads the deployment bucket, signs channel
+ pointers when it holds a key.
+- **keymail relay** (`keymail-relay.service`, flagship) — binds the MX port
+ `:25` directly, serves HTTP on `127.0.0.1:8446` behind the edge, spawns
+ one `keymail instance` child per mailbox over sockets in `/run/keymail`,
+ and replicates its own SQLite files with embedded litestream.
+
+Neither unit exists as text in the infra repo. `platform-infrastructure`
+contains operational references only (`systemctl restart console.service`
+in `scripts/rollout-status-flagship.sh`, a *rehearsal* unit heredoc in
+`scripts/rehearse-keymail.sh`) — the production units live on the boxes and
+nowhere else. That is the sharpest statement of the problem: the two most
+important long-running processes in the fleet are configured by memory.
+
+What the special-casing costs today, concretely:
+
+- The metrics sampler scans `carlos-*.service` cgroups
+ (`internal/host/metrics_probes.go`), so neither app appears in the
+ operator per-unit table, and `keymail.dev`'s owner page renders "no
+ per-instance readings" while the relay is serving mail. The monitoring
+ spec (`2026-08-02-host-process-monitoring-design.md` §1) already *claims*
+ "the console and keymail are themselves `carlos-app@` instances". This
+ design makes that sentence true.
+- Adoption's restart is hand-scripted (`systemctl restart console.service`
+ over SSM) rather than owned by the promote → adopt → restart order.
+- Config delivery, litestream coverage, and every future platform feature
+ has to remember these two by name.
+
+Scope: the unit shape of an app. Nothing here touches the console's
+security gates or their ordering, the channel ladder, or the pinned
+production channel names (`internal/release/laddercheck_test.go` — `canary/*`
+keeps working exactly as today; the ladder is not consulted anywhere in
+this design).
+
+## 1. What the two apps actually need
+
+Derived, not imagined. Each row is something the bespoke unit does today
+that the template cannot express.
+
+| Need | console | keymail relay | Template today |
+|---|---|---|---|
+| A verb other than `serve` on the ExecStart line | no (`serve`) | yes (`relay`) | fixed `serve` |
+| Long-running: enabled, started at boot, restarted on adopt, never hibernated | yes | yes | nothing enables an instance at all |
+| Direct bind of a privileged port (`:25`) | no | yes | `CapabilityBoundingSet=` empty; no listener but the unix socket |
+| A persistent data dir the platform provisions, owns, and knows how to replicate | yes (`console.db`) | yes (app replicates itself) | `StateDirectory` exists; the DB filename is unknown to the platform |
+| A runtime dir at a fixed absolute path (`/run/keymail`) | no | yes | none |
+| Forks a process family into its own cgroup | no | yes (1 + N mailboxes) | `TasksMax=256`, `MemoryMax=512M` — a single-process budget |
+| Uses the box's cloud instance role from inside the process (IMDS) | yes (S3: releases, metrics, pointers) | yes (litestream, sealed blobs, SES) | `DynamicUser=yes` mints an ephemeral uid; the box's IMDS firewall gate admits root and `carlos` only |
+
+The last row is not theory. During the keymail rehearsal the IMDS uid gate
+starved the relay's in-process AWS clients, restore-on-boot silently created
+empty databases, and the relay served them
+(`docs/superpowers/plans/2026-08-02-plan-keymail-flagship.md`, finding F1).
+Any design that ignores it does not migrate keymail.
+
+Everything else both apps need — origins, RP IDs, bucket names, PoW cost,
+listen addresses — is **environment**, which the config plane already
+delivers to unit-backed routes (`appconfig.Sync` writes
+`/etc/carlos/apps/<host>.env`, which the template already reads). No
+capability is needed for any of it, and none is defined for any of it.
+
+## 2. The capability model
+
+An app **declares** what it needs from the platform; the platform decides
+how (and whether) to provide it. The vocabulary is **closed**: seven names,
+each mapping to a fixed set of vetted systemd directives that live in this
+repo, never in a manifest. A manifest never names a path, a user, a uid, a
+resource number, or a systemd directive.
+
+**Self-service** — affects only the app's own sandbox, materializes from
+the manifest alone:
+
+- **`command`** — the single subcommand token appended to the template's
+ ExecStart in place of `serve` (keymail: `relay`). Charset
+ `^[a-z][a-z0-9-]{0,31}$`, one token, no flags. Flags and values are
+ environment; keymail already reads all of its own from `KEYMAIL_*`.
+- **`daemon`** — long-running. The platform `enable`s the instance (so it
+ survives reboot), starts it if inactive, **restarts** rather than
+ try-restarts it on adopt, and refuses hibernation for the route. This is
+ the capability that makes "adoption swaps a symlink; it does not restart
+ processes" survivable: the platform owns the restart, in the documented
+ promote → adopt → restart order, instead of an operator remembering it.
+- **`children`** — the app forks a family. The unit is sized for a family
+ rather than a process (platform-chosen `TasksMax` / `MemoryMax` tier, not
+ manifest-chosen numbers), `KillMode=control-group` is pinned explicitly so
+ a stop takes the whole family, and monitoring reads the cgroup as one
+ honest row for the family.
+- **`data`** — a persistent per-app data dir. The path is **always**
+ `StateDirectory=carlos-app/%i` (`/var/lib/carlos-app/<host>`); the
+ manifest may not name it. What the manifest adds is the part the platform
+ cannot guess: which files under it are databases, and who replicates them.
+- **`runtime_dir`** — `/run/<name>`, `RuntimeDirectory=<name>`,
+ `RuntimeDirectoryMode=0700`, `RuntimeDirectoryPreserve=restart`. The name
+ matters for keymail because its restored `registry.db` stores **absolute**
+ socket paths (`/run/keymail/<local>.sock`; finding F2), so the platform
+ must let the app name the directory — inside a closed charset and a
+ reserved-name list (§7).
+
+**Operator-consented** — consumes a shared, scarce, or box-authority
+resource. The manifest declares the need; nothing materializes until an
+operator has granted it *on this box*:
+
+- **`ports`** — direct binds of privileged ports (`< 1024`). keymail: `25`.
+- **`host_role`** — the app's processes may use the box's cloud instance
+ role. This is a real transfer of authority (the flagship role can read and
+ write the deployment bucket, keymail's blob bucket, and send SES mail), so
+ it can never be self-granted by tenant input.
+
+The split is the whole security model in one sentence: **a capability that
+only shapes the app's own sandbox is the app's to declare; a capability that
+reaches anything shared is the operator's to grant.**
+
+### Grants
+
+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
+```
+
+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 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.
+
+## 3. Wire and storage shapes
+
+### 3a. The manifest field (additive, schema stays 1)
+
+`release.Manifest` gains one optional object. Precedent:
+`appconfig.Manifest.ExecDelivery` (issue #18) added a field to a schema-1
+manifest without bumping the schema, because an old binary must still parse
+what a new one writes.
+
+```json
+{
+ "schema": 1,
+ "app": "keymail",
+ "version": "6927be7",
+ "kind": "binary",
+ "artifacts": { "linux-arm64": { "file": "linux-arm64", "sha256": "…", "size": 14680064 } },
+ "capabilities": {
+ "v": 1,
+ "command": "relay",
+ "daemon": true,
+ "children": true,
+ "runtime_dir": "keymail",
+ "ports": [ { "port": 25, "name": "mx" } ],
+ "data": { "replicate": "app" },
+ "host_role": true
+ }
+}
+```
+
+```json
+ "capabilities": {
+ "v": 1,
+ "daemon": true,
+ "data": { "replicate": "platform", "dbs": ["console.db"] },
+ "host_role": true
+ }
+```
+
+Field rules:
+
+- `"v"` is the capability block's own version, matching the `"v": 1`
+ discipline the metrics objects use. `schema` stays 1 forever for this
+ change; every manifest already in a production bucket has no
+ `capabilities` key, parses unchanged, and means "no capabilities" — which
+ is today's behaviour byte for byte.
+- `data.replicate` is `"platform"` (the box-wide root `carlos-litestream`
+ unit replicates the named `dbs`) or `"app"` (the app streams its own WAL;
+ the platform provisions the directory and replicates **nothing**). Two
+ replicators on one WAL fight over checkpointing and corrupt the replica —
+ the hazard `ops.FleetDBs` already documents for exec children. `dbs` names
+ bare filenames inside the state dir (`^[A-Za-z0-9._-]{1,64}$`, no
+ separators, no `..`), and is meaningless with `"app"`.
+- `ports[].name` is a label for operators and logs. It is not a systemd
+ `FileDescriptorName` (see §4c) and carries no behaviour.
+
+**Why the release manifest and not the config manifest.** `exec_delivery`
+lives in the config plane because it describes delivery to a supervisor the
+platform does not own, and it is a property of an app, not a build.
+Capabilities describe the **unit the platform does own**, and they change
+with the code: the version that starts binding `:25` is a specific version.
+Putting them in the immutable, content-addressed release manifest means (a)
+a rollback rolls back the unit shape with the binary, (b) the declaration
+rides the same trust chain as the bytes — signed pointer → immutable
+manifest → sha256 — with no second CAS object to keep in step, and (c) there
+is no window in which a running binary and its unit disagree about what the
+app is.
+
+### 3b. Forward compatibility, and which way each failure falls
+
+- Unknown **top-level** manifest key: ignored (Go's decoder already does
+ this). Old binaries keep parsing new manifests.
+- Unknown **field inside a known capability**: ignored, one log line. Room
+ to grow a capability additively.
+- Unknown **capability name**, or `"v"` greater than this binary knows: the
+ **version is refused on this box** — not staged, not pointed at, one log
+ line per pass naming the capability and the fix ("update the platform
+ binary"). Fail closed, because the alternative is materializing a unit
+ that is missing something the app says it needs and looks healthy anyway —
+ the config-that-lies failure mode that shaped issue #18. The box keeps
+ serving the version it has, which for a daemon is the safe direction.
+- Ship-time validation refuses anything this binary does not understand,
+ where a human is present to read the refusal. Same value set, one
+ `ValidCapabilities` function shared by ship and adopt, mirroring
+ `ValidExecDelivery`.
+
+**Deploy order, pinned:** platform binaries first, capability-bearing
+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).
+
+### 3c. On-box storage
+
+- **Staged manifest**: `ensureStaged` writes the manifest it already fetched
+ to `<opt>/versions/<ver>/[<acct>/]<app>.manifest.json` — a sibling file of
+ the staged binary, so no directory shape changes and an old box's tree
+ stays valid. This is what lets the converge pass read a *running* version's
+ capabilities without a bucket round-trip on every tick.
+- **Registry**: one additive column,
+ `ALTER TABLE routes ADD COLUMN caps TEXT NOT NULL DEFAULT ''` — the
+ granted tokens, comma-separated (`port:25,host-role`), written only by the
+ narrow setter behind `carlos route -grant/-revoke`. Default `''` = nothing
+ granted = today's behaviour for every row already on every box. It is
+ deliberately **not** added to `Fingerprint()`: grants change unit shape,
+ not routing, and the edge has no reason to reload for one.
+- **Drop-ins**, generated, root-owned, `0644`:
+
+```
+/etc/systemd/system/carlos-app@<host>.service.d/10-carlos-capabilities.conf
+/etc/systemd/system/carlos-app@<host>.socket.d/10-carlos-capabilities.conf
+/etc/systemd/system/carlos-app@<host>.service.d/90-operator.conf ← never written or read by the platform
+```
+
+Each generated file opens with `# GENERATED by carlos adopt — do not
+hand-edit`, the marker `carlos ops litestream` already uses. A file at that
+path **without** the marker is never overwritten and never deleted; it is
+reported and skipped. `10-` sorts before `90-`, so an operator drop-in wins
+every conflict, which is the escape hatch for anything this vocabulary
+cannot say.
+
+One generated file per unit, not one per capability. Removal semantics are
+the reason: a capability disappearing from a manifest is then one rewrite of
+one file, with no orphan-file reaping pass to get wrong, and "which
+capability wrote this line" is answered by the manifest, which is on disk
+next to the binary.
+
+## 4. How capabilities materialize
+
+### 4a. One template, drop-ins per instance
+
+`internal/host/units/carlos-app@.service` and `carlos-app@.socket` stay
+exactly as they are — one template, no variants, no per-app unit files. Every
+capability is expressed as directives in the per-instance drop-in. Rendered
+deterministically (fixed directive order, sorted lists) so an unchanged pass
+writes no bytes, triggers no `daemon-reload`, and restarts nothing.
+
+Worked example — `carlos-app@keymail.dev.service.d/10-carlos-capabilities.conf`:
+
+```ini
+# GENERATED by carlos adopt — do not hand-edit
+[Service]
+ExecStart=
+ExecStart=/opt/carlos/live/%i relay
+RuntimeDirectory=keymail
+RuntimeDirectoryMode=0700
+RuntimeDirectoryPreserve=restart
+KillMode=control-group
+TasksMax=1024
+MemoryMax=1G
+DynamicUser=no
+User=carlos-keymail
+Group=carlos-keymail
+```
+
+and `carlos-app@keymail.dev.socket.d/10-carlos-capabilities.conf`:
+
+```ini
+# GENERATED by carlos adopt — do not hand-edit
+[Socket]
+ListenStream=25
+```
+
+console's is three lines plus the header: the template's `ExecStart` already
+says `serve`, `daemon` adds no directives (it is enable/start/restart
+behaviour, not unit text), and only `host_role` writes anything —
+`DynamicUser=no`, `User=carlos-console`, `Group=carlos-console`.
+
+The empty `ExecStart=` before the real one is systemd's documented way to
+reset a list-valued directive from a drop-in; without it the unit would have
+two ExecStart lines and refuse to start.
+
+### 4b. `host_role`: a static user, and why
+
+`DynamicUser=yes` mints an ephemeral uid per start. The box's IMDS gate is
+an nftables rule keyed on `skuid`, so an ephemeral uid can never be on its
+allowlist — and the observed consequence is not an error but silence
+(keymail's restore came up empty and served). Matching the rule on the
+unit's cgroup instead was considered and rejected: nftables resolves a
+`cgroupv2` path to a numeric id when the rule is loaded, and every service
+restart creates a new cgroup id, so the rule would go stale on the first
+restart — a firewall that silently stops matching is worse than no firewall.
+
+So `host_role` drops `DynamicUser` and runs the unit as a platform-created
+system user `carlos-<app>` (locked, no home, `nologin`, created idempotently
+by the adopt pass exactly as `ensureServiceUser` creates `carlos`). The name
+is derived by the platform from the app name, never taken from the manifest;
+an app name long enough to overflow the 32-character account limit is
+refused at ship time.
+
+The nft allowlist entry for that uid is **not** written by the platform. The
+firewall is operator territory (and classifier-blocked for agents); the
+adopt pass prints the exact command once per pass while the grant exists and
+the uid is absent from the gate. Naming the missing step every hour is the
+whole lesson of F1.
+
+**Pinned sharp edge:** with `DynamicUser=yes`, systemd materialises
+`StateDirectory` under `/var/lib/private/` with a symlink at
+`/var/lib/carlos-app/<host>`; with a static `User=` it expects a real
+directory. Flipping `host_role` on an app whose state dir already exists in
+the other shape is therefore **refused in place** — the converge pass logs
+and skips, and the operator does it as a stop → move → start. Neither
+console nor keymail is a DynamicUser tenant today, so both migrate directly
+onto the static shape with no conversion.
+
+### 4c. `ports`: socket units, never `CAP_NET_BIND_SERVICE`
+
+The port is bound by **systemd, as root, in the socket unit**, and the
+already-bound fd is handed to the tenant. The tenant never holds a
+capability.
+
+Rejected alternative: `AmbientCapabilities=CAP_NET_BIND_SERVICE` on the
+service. It is one line and needs no app change, and it is the wrong grant:
+the capability is "bind any port below 1024", on a box where the edge owns
+`:80` and `:443`. A tenant that restarts at the wrong moment — or is
+compromised — could bind the edge's ports and serve the fleet's traffic.
+"Port 25" and "every privileged port" are not the same request, and only one
+of them is what keymail asked for. Reopening the template's
+`CapabilityBoundingSet=` per tenant is not a trade this platform should make
+to save an app one flag.
+
+Socket activation is also the house pattern already: `carlos-app@.socket`
+exists precisely so root binds and the tenant inherits, "the simplest design
+that gets the PERMISSIONS right". `ports` adds `ListenStream=` lines to that
+same per-instance socket unit rather than inventing a second family of unit
+files.
+
+Two operational consequences, both good: the listener is bound at boot
+before the service starts, so an MX restart never refuses a connection (it
+queues in the backlog) — strictly better than today, where a relay restart
+drops `:25` entirely; and a connection to `:25` can start a stopped unit,
+though `daemon` means it should never be stopped.
+
+**App-side contract**: the app enumerates `LISTEN_FDS` (from fd 3) and
+identifies each listener by its own local address — a `*net.TCPAddr` on port
+25 is the MX, a `*net.UnixAddr` is the HTTP upstream. Deliberately **not**
+an ordering or `FileDescriptorName` contract: `FileDescriptorName=` names a
+whole socket unit, not an individual `ListenStream=`, so a name could not
+distinguish two listeners in one unit, and an order contract would be a
+silent-corruption bug the first time a drop-in is re-rendered. Matching on
+the address the kernel reports cannot drift.
+
+For keymail this is one flag, the sibling of K0's `-public-origin`: accept
+the MX listener from `LISTEN_FDS` when present, keep binding `:25` when not.
+Until it lands, keymail migrates everything except `ports` — see §5.
+
+### 4d. Where the pass runs, and in what order
+
+`/etc/systemd/system` is root, so this belongs to `RunAdopt`
+(`carlos-adopt.service`, hourly plus the operator's "converge now") — the
+same placement-follows-capability argument that keeps the unit half of
+config sync there and the exec half in the agent. `RunAdopt` becomes:
+
+1. `appconfig.Sync` (unchanged, first: a route's env exists before its
+ process starts).
+2. **Capability converge** for every adoptable unit route, from the
+ **staged manifest of its live version** — repairs drift: a deleted
+ drop-in, a fresh grant, a revoked grant, a unit that is not enabled, a
+ daemon that is not running.
+3. `release.Pass` (unchanged in shape). Inside `adoptHost`, on a version
+ change, the order is now: `ensureStaged` (binary + manifest) → **converge
+ capabilities for the new version** → `PointLive` → restart. A restart
+ failure rolls back the symlink *and* restores the previous drop-ins, then
+ poisons the version for the rest of the pass, exactly as today.
+4. Advisories (`adviseBackingMismatches`, plus one new line — §5).
+
+Both entry points call the same converge function; step 2 is what a *grant*
+change moves through, step 3 is what a *version* change moves through, and
+running both is idempotent.
+
+**The converge pass never removes a drop-in because a manifest is
+unreadable.** Absent or unparseable staged manifest ⇒ leave the drop-ins
+exactly as they are and log, every pass. The same three-state rule
+`appconfig.Sync` uses (converge / never-configured / failed-leave-alone),
+for the same reason: a bucket hiccup must not strip `:25` off a running mail
+server. A capability is only removed when a manifest that *actually
+resolved* no longer declares it, or a grant was revoked.
+
+Restart ownership: `restartRoute` keeps `systemctl try-restart` for every
+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.
+
+### 4e. Backing, and the rails around it
+
+Capabilities are honoured only for `Kind == "instance"`, `Backing ==
+"unit"`, `!Platform` — the same filter `release.adoptable` and
+`appconfig.syncable` already apply. So both apps become `Backing="unit"`
+routes, and everything the unit path already provides (env files, restart on
+config change, per-unit metrics, the `-db` replication hook) arrives with
+them.
+
+- A **platform alias** route (`<app>.<sqid>.<apps-domain>`) never
+ materialises anything. An alias is a second name for one process; a second
+ `carlos-app@` unit claiming `:25` would break the real one. `adoptable`
+ already excludes them; this design depends on that and says so.
+- Additional hostnames for one daemon are `kind=service` proxy routes, never
+ a second `kind=instance` route. `status.keymail.dev` is exactly this case:
+ one relay process, two names, one unit.
+- An **exec-backed** route (`Backing == ""`) whose release declares
+ capabilities materialises nothing and gets an advisory line — the same
+ shape as the issue #14 backing-mismatch advisory, and the guided path
+ through the migration in §5.
+- `daemon` + `hibernate` is refused by `carlos add` / `carlos route`. A
+ daemon the platform is asked to keep running and to stop when idle is a
+ contradiction, and hibernation for these apps means an MX that sleeps.
+- `ports` conflicts are refused before anything is written: a port already
+ claimed by another route's drop-in on this box, or in the reserved set
+ (§7), does not materialise.
+
+## 5. Migration
+
+Both migrations are per box, independent, and reversible. Neither is
+literally zero-downtime — a single-writer SQLite app cannot have two
+processes on one database, so the cutover is stop → copy → start. The
+honest number is **seconds**, and the design makes the risky part (the data)
+a copy rather than a move, so rollback has a body to return to.
+
+### 5a. Prerequisites (both apps)
+
+1. Platform binary with capability support on the box first
+ (`update-platform-binary.sh` — it already re-stages the unit family from
+ `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 …`).
+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).
+
+### 5b. console
+
+console is the thing that ships and promotes, so the chicken-and-egg
+deserves naming — and then defusing:
+
+- **The restarter is not the console.** Adoption runs in `carlos adopt`
+ (root, `carlos-adopt.service`) or in the agent, never inside the console
+ process. `systemctl restart carlos-app@platform.carlosframework.com`
+ restarting the console is no more circular than the SSM script that does
+ it today.
+- **A promote in flight is a CAS write.** Worst case for a restart landing
+ mid-promote is that the pointer was not written; nothing is half-written,
+ because `signPointer` + `Put(…, etag)` is atomic.
+- **A console build that will not boot cannot be rolled back from the
+ console.** The escape hatch is and stays the CLI: `carlos rollback` over
+ SSM, which repoints and — on `canary/console`, which has no bake window —
+ is picked up by the canary fast loop within seconds.
+- **Once the platform owns the restart, promoting console *is* deploying
+ console.** Today an operator chooses the moment by running the restart by
+ hand. That choice moves into the promotion. On `canary/console` with zero
+ hold that is immediate; it is the right default and it should be a
+ deliberate one (open question 3).
+
+Steps, per box:
+
+1. `carlos env set -app console …` — move console's environment from the
+ bespoke unit's `EnvironmentFile` into the bucket, including
+ `CONSOLE_DB=/var/lib/carlos-app/<host>/console.db`. Nothing reads it yet.
+2. Ship a console release whose manifest declares `daemon`, `data`
+ (`replicate: platform`, `dbs: ["console.db"]`), `host_role`. Promote it
+ 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 -backing unit <host>` (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.
+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.
+
+### 5c. keymail relay
+
+Gated on one keymail change: accept the MX listener from `LISTEN_FDS`
+(§4c). Until it merges, keymail migrates **without** `ports` — which means
+`:25` cannot move, which means the relay cannot move. There is no half
+migration for a single process, and pretending otherwise would put an MX
+gap into a plan (open question 1).
+
+Steps, once the flag is merged and shipped:
+
+1. `carlos env set -app keymail …` — `KEYMAIL_RELAY_ADDR=127.0.0.1:8446`,
+ `KEYMAIL_HOST`, `KEYMAIL_RP_ID`, `KEYMAIL_PUBLIC_ORIGIN`,
+ `KEYMAIL_DATA=/var/lib/carlos-app/keymail.dev`,
+ `KEYMAIL_SOCK_DIR=/run/keymail`, the bucket names, the SES pair;
+ `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: `port:25`, `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`,
+ 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. 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
+ 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.
+
+### 5d. What the infra repo loses
+
+- `scripts/rollout-status-flagship.sh` — the `systemctl restart
+ console.service` / `is-active` pair, and the two-guess
+ `CONSOLE_DB`/binary-path fallback for the operator grant, whose existence
+ is itself the argument for this design.
+- `scripts/rehearse-keymail.sh` — the unit heredoc, the `useradd`, the
+ `install -d` calls for the data and runtime dirs, and the hand-written
+ `EnvironmentFile`; a rehearsal becomes a route plus a channel.
+- `scripts/harden-host.sh` — the per-unit hardening drop-in retrofit for
+ these two units; the template already carries the sandbox. The IMDS uid
+ allowance stays (it is a firewall, not a unit shape) and gains the
+ `carlos-<app>` uids.
+- The `flagship/README.md` prose that has to remember what is running.
+
+What does **not** shrink: `ship-app.sh` / `promote-app.sh` /
+`promote-console.sh`. Shipping and promoting are orthogonal to unit shape,
+and the signing key keeps coming from SSM inside the script.
+
+## 6. What monitoring gets for free
+
+This design exists because `keymail.dev` rendered "asleep" on its owner page
+while it was serving mail. Once both apps are `carlos-app@<host>.service`
+instances, with no change at all to the monitoring code:
+
+- `listCarlosUnits` finds them — instantiated units land in
+ `system.slice/system-carlos\x2dapp.slice/`, which the sampler already
+ scans one level down for. Both appear in the operator per-unit table with
+ real CPU, memory, and io.
+- `OwnerAppMetrics` matches `carlos-app@<host>.service` exactly, derived
+ from the app's own routes filtered by account. keymail.dev's owner page
+ shows per-instance readings instead of "no per-instance readings".
+- keymail's mailbox children are inside the unit's cgroup, so the family
+ reports as one honest row — relay plus 18 instances, attributed to the app
+ that spawns them, which is precisely the noisy-neighbour question the
+ per-unit table exists to answer.
+- `InstanceMetrics.Hibernate` is false for a daemon, so absence renders as
+ the state-neutral "no per-instance readings" rather than a sleep claim
+ nobody observed — and with `daemon` the platform now knows that absence is
+ a **fault**, not a state. Turning that into an alert is a follow-on (the
+ monitoring spec puts per-unit alerts out of v1); the data is now there to
+ do it.
+- `ops.FleetDBs` learns console's database from `data.dbs` — stamped onto
+ 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".
+- 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 →
+ restart order, on every box, every pass — not a line in an SSM script.
+
+## 7. Security posture
+
+The manifest is **tenant input**. Anyone who can ship to an app writes it.
+The design assumes a malicious one and holds by construction:
+
+- **No systemd directives from manifests.** The vocabulary is seven names;
+ the directives they map to are Go string constants in this repo. There is
+ no passthrough, no `extra`, no escape hatch — the escape hatch is
+ `90-operator.conf`, which only root can write and which the platform never
+ reads.
+- **No paths.** `data` cannot name a directory; the state dir is always
+ `/var/lib/carlos-app/<host>`. A manifest saying `"path": "/etc"` has
+ nowhere to say it, so the platform can never be induced to chown, create,
+ or expose a path of the tenant's choosing. `data.dbs` entries are bare
+ filenames, validated against `^[A-Za-z0-9._-]{1,64}$`, rejected on `.`,
+ `..`, or any separator, and used only as `filepath.Join(stateDir, name)`.
+- **No users, uids, or groups.** `host_role` runs as `carlos-<app>`, derived
+ by the platform. A manifest cannot ask to be `root`, `carlos`, or another
+ tenant's user.
+- **No numbers.** `children` selects a platform-chosen tier;
+ `TasksMax=infinity` and `MemoryMax=infinity` are unsayable. A tenant
+ cannot size itself into a box-wide denial of service.
+- **`runtime_dir` is namespace-guarded twice.** Charset
+ `^[a-z][a-z0-9-]{0,31}$` (so `../etc`, `carlos/apps`, and absolute paths
+ do not parse), a reserved list that includes `carlos` — a tenant owning
+ `/run/carlos` would own every tenant socket the edge dials, the single
+ most valuable directory on the box — plus `systemd`, `lock`, `log`,
+ `user`, `dbus`, `sshd`, `udev`, and anything matching `systemd-*`; and a
+ box-wide uniqueness check, so the second app to claim `keymail` is refused
+ rather than handed a directory another app's sockets live in.
+- **`ports` is grant-gated, conflict-checked, and reserve-guarded.** `22`,
+ `80`, `443` are reserved absolutely and are **not grantable** — an
+ operator typo cannot hand a tenant the edge's ports or the box's SSH.
+ Ports already claimed by another route on this box are refused. Only
+ privileged ports are accepted at all: a high port needs no help from the
+ platform, and letting the vocabulary cover them would blur what the
+ capability is for.
+- **`host_role` is the one real transfer of authority, and it is
+ operator-only, per box.** It hands the app the box's IAM role. It cannot
+ be self-granted, its grant lives in the box's registry rather than in
+ anything a tenant can write, and the firewall entry that actually makes it
+ work is a separate operator action the platform only ever *prints*. Two
+ independent humans-in-the-loop for the capability that matters most.
+- **A grant is not a promise.** Revoking `port:25` rewrites the drop-in and
+ restarts the unit on the next pass; there is no path by which a manifest
+ re-grants itself.
+- **Nothing here touches the console's gates.** Session → CSRF → owner
+ (byte-identical 404) → `requireFreshAuth` last, unchanged. The console
+ gains, at most, a read-only rendering of which capabilities a route has
+ been granted; grants are written by `carlos route` on the box.
+
+Residual risk, stated plainly: `host_role` means a compromised console or
+relay binary has the box's IAM role — as both do today, because that is how
+they already work. This design does not widen it; it makes it a named,
+grantable, revocable, per-box thing instead of a property of a unit file
+nobody has a copy of.
+
+## 8. Testing
+
+- Pure-function tests for capability validation (unknown name refused at
+ ship, unknown field ignored, `"v"` bump refused, charset and reserved-name
+ refusals for `runtime_dir`, reserved and non-privileged port refusals,
+ `daemon`+`hibernate` refusal).
+- Golden-file tests for the rendered drop-ins — console's and keymail's,
+ byte for byte — plus a determinism test (render twice, identical bytes,
+ no write, no `daemon-reload`).
+- A parses-yesterday's-manifest test: a manifest with no `capabilities` key
+ round-trips and yields no capabilities, joining `golden_test.go`'s
+ existing byte-identical guarantees.
+- Converge-pass tests at the `Systemctl`-style seam the activator already
+ uses, so nothing touches systemd: grant added → drop-in written and unit
+ restarted; grant revoked → drop-in rewritten; manifest unreadable →
+ drop-ins untouched, logged; hand-written drop-in (no marker) → never
+ overwritten; version change → capabilities converge before `PointLive`,
+ and a restart failure restores both the symlink and the previous drop-ins.
+- Port-conflict and `runtime_dir`-collision tests across two routes on one
+ box.
+- A `laddercheck_test.go`-style assertion that no path in this design
+ consults or constrains channel names — `canary/*` in, `canary/*` out.
+- `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l`
+ clean before anything lands.
+
+## 9. Non-goals
+
+- Free-form systemd directives, free argv, or per-app resource numbers from
+ a manifest. Ever, not "not yet".
+- Capabilities for exec-backed routes. They get an advisory, not a
+ materialization.
+- Hibernation for daemon apps.
+- Multi-box placement. Which box runs a daemon is still the route row's
+ answer, as it is today.
+- Agent-managed sidecars for unit tenants (`UnitBackend` refuses them; that
+ is unchanged).
+- UDP sockets, non-TCP protocols, and edge termination for anything but
+ HTTP. `:25` stays a direct bind because the edge does not speak SMTP.
+- Automatic firewall/IMDS allowlisting. The platform prints the command.
+- Cross-tenant shared data directories, or one static user shared by two
+ apps.
+- Migrating any *third* app. The vocabulary was derived from two; the next
+ app that needs something absent from it is a design conversation, not an
+ `extra` field.
+
+## 10. Open questions for Paul
+
+1. **keymail's `LISTEN_FDS` change is the gate on the whole keymail
+ migration.** Take it (recommended — sibling of K0's `-public-origin`,
+ and it also removes the MX gap on restart), or accept an interim
+ `AmbientCapabilities=CAP_NET_BIND_SERVICE` grant knowing it means "any
+ privileged port" on the box that owns `:443`?
+2. **`carlos-<app>` static users and the nft gate.** Accept a per-app system
+ user plus an operator-run allowlist entry per box, or is there an
+ appetite for moving the IMDS gate onto something that survives restarts
+ better than a uid (nothing I found does — cgroup matching goes stale)?
+3. **Console: promotion becomes deployment.** On `canary/console` (zero
+ bake) a promote restarts the console within seconds of the fast-adopt
+ loop noticing. Keep that, or give console a bake window / an explicit
+ hold so the restart moment stays a human's?
+4. **Grant scope.** Per route, per box (proposed — the resources are
+ box-local), or per app fleet-wide with the box merely enforcing?
+5. **Console's data location.** Move `console.db` into
+ `/var/lib/carlos-app/<host>/` (proposed — the platform then owns and
+ replicates it), or keep `/data/console` via an operator drop-in and
+ accept that the platform does not know where console's database is?