carlos / platform Public

spec: rework after the Fable review — two blockers, one found verifying it

The review found two blockers and I verified both against the code rather
than taking them on trust. Verifying the second turned up a third that the
review did not reach.

BLOCKER 1 — §6 defeated the spec's own purpose. It had control-plane apps
demand fresh auth to promote "exactly as stable does, at every rung". But
stable's gate is a bare 403 with no API-side step-up (apicli.go:632-638)
and carlos deploy log.Fatalfs on a failed promote AFTER the ship has
landed (deploy.go:206-208) — so `carlos deploy -app console`, the command
this spec exists to create, would have shipped and then died every time.

Inverted: the flag now gates DESTRUCTION, not promotion. Deploying the
console should be easy; destroying it should be hard — and that is where
the danger actually was, since handlePurgeApp can delete the release
history the fleet is currently running behind an ordinary confirmation. It
also refuses promotion by a service principal, which closes the CI-rolls-
the-console hole without needing a step-up flow at all.

BLOCKER 2 — §4/§8 overclaimed the rollback. adoptHost rolls back only when
systemctl try-restart exits non-zero, and a Type=simple unit exits 0 the
moment the process is exec'd. A console that starts and then panics stamps
the new build fleet-wide with no rollback. Now requires a readiness gate on
console.service (a pinfra + cmd/console change this spec owns) and states
the residual plainly instead of implying it is covered.

BLOCKER 3, found while verifying 2 and not in the review — the rollback
leaves the console DOWN and the NEXT pass then lies. adoptHost repoints the
symlink but never starts the unit, and try-restart is a no-op on a unit
that is not running, so pass 2 exits 0 and stamps SetVersions over a
console that is not running at all. Adoption must START the unit on the
restored version. This is a tenant fix: every unit-backed route has the
hole, and it has stayed hidden because the pinfra script's explicit
post-pass restart papers over it on the fleet's only stamped route.

Also: §4c now rules that the stamped route must be the host named in
ExecStart, not the canonical hostname — flagship has these disagreeing
today, which is why the pinfra script's explicit restart is load-bearing;
§3a names the build probe's storage and consumer list and rules it onto the
app row rather than provision.Record (read-modify-write would drop it);
§3b closes the ldflags trap that would have made a healthy deploy report
failure forever; §8 documents the migrateAppsKey column-drop trap;
§2 pins ownerClaim false for service principals and corrects the claim that
derp/meep are unblocked (they hold grants); §10 reorders so the flag exists
before the claim and the code reaches the boxes before the first
self-deploy, and adds the tito survey it was missing.

Step 0 resolved in the spec's favour: no CLI grant on ops/console, so the
owner exemption is sufficient. Flagship's readings are in a new appendix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 06cc99867c87c257233caab6bf65df47a635e350 parent 6364119
1 file changed, +438 −243
  • docs/superpowers/specs/2026-08-23-console-as-an-app-design.md +438 −243
diff --git a/docs/superpowers/specs/2026-08-23-console-as-an-app-design.md b/docs/superpowers/specs/2026-08-23-console-as-an-app-design.md
index a3bf61bb7..6a9b61c62 100644
--- a/docs/superpowers/specs/2026-08-23-console-as-an-app-design.md
+++ b/docs/superpowers/specs/2026-08-23-console-as-an-app-design.md
@@ -1,6 +1,7 @@
# The console is an app — retiring the pinfra roll
-**Date:** 2026-08-23 · **Status:** approved (Paul, in-session) · **Slice:** 1 of 4
+**Date:** 2026-08-23 · **Status:** approved (Paul, in-session); reworked after a
+Fable review · **Slice:** 1 of 4
## Problem
@@ -15,14 +16,18 @@ platform: roll-console.sh <platform-checkout> both # pinfra + AWS + SSM
That inversion is the thing CLAUDE.md's own rule complains about ("every
`carlos` command is designed for a user with no access to the underlying
-infra"), and it has a cost beyond inconvenience: a special door is where our
-own deficiencies hide. Everything that makes rolling the console feel
-dangerous — a version header that lies, a restart nobody trusts, a rollback
-verified by hand — is a deficiency **tenants have too**. They just complain
-less. While the platform team has its own door, nothing forces us to feel it.
+infra"), and it costs more than convenience: a special door is where our own
+deficiencies hide. Everything that makes rolling the console feel dangerous — a
+version header that lies, a restart nobody trusts, a rollback verified by hand
+— is a deficiency **tenants have too**. They just complain less. While the
+platform team has its own door, nothing forces us to feel it.
This slice makes the console an ordinary app, deployed by the ordinary verb.
+Three of the five fixes below (honest build verification §3, the dirty-tree
+guard §7, and the restart-recovery fix §4b) are **tenant** fixes that this work
+merely forces us to make.
+
### Not in this slice
`carlos system` is **deferred, not rejected** (Paul, 2026-08-23). It earns its
@@ -30,9 +35,7 @@ place for what genuinely cannot be an app: the root `carlos` binary, the edge
router, the activator, the provisioning reconciler. Those have no route, no
channel to be adopted from and no upstream — they are the substrate that does
the adopting and routing. `update-platform-binary.sh` is 85 lines of exactly
-that work (stage embedded units, sysusers *before* tmpfiles, daemon-reload,
-*arm* timers and path units rather than merely enable them), none of which has
-an app-shaped equivalent. That is slice 3.
+that work, none of which has an app-shaped equivalent. That is slice 3.
The dividing line, stated once so later work can apply it: **`carlos system` is
for what is not an app. Anything adoptable through a route is an app.**
@@ -41,328 +44,520 @@ for what is not an app. Anything adoptable through a route is an app.**
`roll-console.sh` and `verify-console.sh` encode failure modes this project has
already paid for. A replacement that drops any of them is a regression wearing
-better clothes. Enumerated so the plan can check them off:
+better clothes.
-| # | Failure mode | Where it is handled after this slice |
+| # | Failure mode | Covered by |
|---|---|---|
-| 1 | amd64 artifact shipped to Graviton boxes | `carlos deploy -target linux-arm64` (already the default) + §5 server-side refusal |
-| 2 | Adoption swaps the symlink; the old process keeps serving | `makeRestartRoute` arm 1, already built (§4) |
-| 3 | SSM shells missing the box env (`set -a; source host.env`) | No SSM at all |
-| 4 | "Verified" reported from a stale binary | §3, self-reported build |
-| 5 | A dirty tree stamping a version that names no real commit | §6 |
+| 1 | amd64 artifact shipped to Graviton boxes | §5 ELF refusal (`-target` default only helps the careful caller) |
+| 2 | Adoption swaps the symlink; the old process keeps serving | §4a — the *right* route stamped |
+| 3 | SSM shells missing `set -a; source host.env` | No SSM at all |
+| 4 | "Verified" reported from a stale binary | §3 build probe |
+| 5 | A dirty tree stamping a version naming no real commit | §7 |
| 6 | sysusers before tmpfiles | Not reached — root-binary concern, slice 3 |
+| 7 | **The console artifact is `cmd/console`, not the root binary** (8-minute outage, 2026-08-08) | §5 embedded-build refusal |
## 1. What is actually in the way
-Four findings from the live estate (2026-08-23), because the gap is smaller
-than it looks and the remaining pieces are specific.
+Verified against the live estate on 2026-08-23. See the appendix for the raw
+readings.
-**The console is already structurally an app.** Its releases are at
-`apps/bab/console/` — properly account-qualified under `ops` — on channel
+**The console is already structurally an app.** Releases at
+`apps/bab/console/`, properly account-qualified under `ops`, on channel
`canary/console`, adopted by the same pass as every tenant, served through
-routes, with one route already naming `console.service`.
+routes, one route already naming `console.service`.
-**But it is not an app *record*.** `ops` on flagship holds exactly two app
-rows: `deploy-probe` and `platform-site`. There is no `console` row, because
+**But it is not an app *record*.** `ops` on flagship holds two app rows:
+`deploy-probe` and `platform-site`. There is no `console` row, because
`ship-app.sh` ships direct-to-bucket and direct mode deliberately skips the
-claim. Without a row the console API cannot see the app at all: `carlos
-channels -app console -account ops` answers the byte-identical 404.
+claim. Without a row the console API cannot see the app at all.
-**Claiming the name is currently refused.** `claimApp`
-(`internal/console/server.go`) lists the app's prefix and returns `claimTaken`
-when anything is there. This is issue **#79**. It also blocks `carloku`,
-`carlosframework`, `derp`, `hello`, `meep` and `apt-carlosframework`, all
-bucket-only apps under `ops`.
+**Claiming the name is refused today** by `claimApp`
+(`internal/console/server.go:2426`) because the prefix has objects — issue
+**#79**.
-**The other three pinfra apps need nothing.** Contrary to CLAUDE.md's framing
+**The other three pinfra apps need no claim.** Contrary to CLAUDE.md's framing
of four equivalent apps, `kass`, `keymail` and `platform-site` are already
-claimed rows with live channels:
-
-```
-kass/kass claimed edge f1479f7 · canary/rehearsal aedcae3-r2
-keymail/keymail claimed beta 053baf4 · canary/keymail 9825fd0
-ops/platform-site claimed canary/site site1
-ops/console NO ROW ← the only #79 case
-```
-
-They are on `ship-app.sh` out of habit, not necessity. Proving `carlos deploy`
-on them and deleting the habit is part of this slice and costs almost nothing.
+claimed rows with live channels. They are on `ship-app.sh` out of habit.
## 2. Claiming an app whose objects already exist (#79)
`claimApp`'s bucket guard is right for a stranger and wrong for the account
that owns the prefix. `apps/<sqid>/<app>/` is namespaced **by account**, so
objects under `apps/bab/` can only be `ops`'s. An owner of `bab` claiming a
-name under `bab`'s own prefix is adopting their own objects, not inheriting a
-stranger's.
-
-The change is one condition:
+name under `bab`'s own prefix is adopting their own objects.
```go
// today — always refuses
if len(keys) > 0 { return claimTaken, "" }
-// after — refuses unless the claimer owns the account the prefix belongs to
+// after — unless the claimer owns the account the prefix belongs to
if len(keys) > 0 && !ownerClaim { return claimTaken, "" }
```
-`ownerClaim` is passed in by the caller, which already resolves the principal.
-It is **owner**, not member: adopting existing release history is an ownership
-act, and both claim doors (`handleAPICreateApp`, `handleNewApp`) know the role.
-
-Four things this must NOT change:
-
+`ownerClaim` is passed by the caller. Five constraints, each of which a naive
+implementation gets wrong:
+
+- **Owner, not member.** Adopting existing release history is an ownership act.
+- **`ownerClaim` is FALSE for every service principal.** A service has no role
+ at all, and an account-scoped `PrincipalService` with `App == ""`
+ legitimately reaches `claimApp` (`apicli.go:346–350` blocks only app-scoped
+ ones). Without an explicit pin, the exemption silently hands every CI
+ credential the power to adopt release history — exactly the widening the
+ svcrole work exists to prevent.
+- **`handleNewApp` (the browser door) must be changed deliberately, not
+ incidentally.** It currently *discards* the role
+ (`addr, raw, acct, _, ok := s.account(…)`, `server.go:2524`) and its doc
+ comment says "Any member may claim." The exemption changes that door's stated
+ policy; the comment changes with it.
- **The legacy prefix still blocks, always.** For `ops`, `claimApp` also checks
`release.LegacyAppPrefix(name)` — `apps/<app>/`, unqualified. Those objects
- predate account scoping and are owed an `accounts migrate -from legacy`;
- handing the name out would put an app in the way of that migration. The
- owner exemption applies **only** to the account-qualified prefix.
- (Verified 2026-08-23: no `apps/console/` exists on flagship, so the console
- does not depend on this exemption being widened.)
+ predate account scoping and are owed an `accounts migrate -from legacy`.
+ The exemption applies **only** to the account-qualified prefix.
- **`HasOwners(name)` still blocks.** A CLI-granted app in `ops` stays
- claim-proof. If `console` turns out to carry a grant, that is a separate
- refusal and the plan's step 0 checks for it before anything else.
-- **Refusals stay byte-identical for non-members.** The console's gate order is
- session → CSRF → owner (byte-identical 404) → `requireFreshAuth` last. A
- non-member must not be able to tell "this name has objects" from "this
- account does not exist". The new branch runs *after* the owner gate, so it is
- only ever reached by someone who has already proved ownership.
-- **The claim is still once, ever.** Nothing here makes a claimed name
- re-claimable.
+ claim-proof.
+
+**Refusals stay byte-identical for non-members.** The new branch runs after the
+owner gate, so only someone who has already proved ownership can reach it.
+
+**What this does and does not unblock.** Verified: the `owners` table holds
+grants for `derp`, `meep` and `platform-site` only. So the exemption unblocks
+`console`, `carloku`, `carlosframework`, `keymail` (the `bab` prefix),
+`hello` and `apt-carlosframework`; `derp` and `meep` stay claim-proof by
+design. An earlier draft claimed otherwise and was wrong.
+
+**One nuance to absorb:** a *trashed* app passes `HasAppIn` (which filters
+`deleted_at = 0`) and is caught later by `CreateAppIn`'s
+`ON CONFLICT DO NOTHING` (`store.go:1120–1128`) → `claimTaken`. No hole, but
+after the exemption a trashed-name claim burns a bucket listing before hitting
+that refusal. Acceptable; noted so it is not mistaken for a bug.
**Test:** an owner claims a name whose account-qualified prefix has objects and
-succeeds, with the app's existing release history immediately visible through
-`channels`; a member who is not an owner gets `claimTaken`; a non-member gets
-the byte-identical 404; a name whose *legacy* prefix has objects is still
+succeeds, release history immediately visible through `channels`; a non-owner
+member gets `claimTaken`; **a service principal gets `claimTaken` whatever its
+scope**; a non-member gets the byte-identical 404; a name whose *legacy* prefix
+has objects is refused even for an owner; a name with an `owners` row is
refused even for an owner.
## 3. Honest verification: the console reports its own build
-`roll-console.sh` reads `readlink /proc/<pid>/exe` over SSM, and
-`verify-console.sh` explains why in fifteen lines: both cheap answers lie.
-`X-Carlos-Version` is stamped by the edge from the **route row**, so adoption
-sets it while the old process keeps serving. `systemctl is-active` reports
-"active" for a process started from the previous version just as happily as
-for the current one — on 2026-08-09 tito said "active" while `/proc` said it
-was still on 9668d30, seconds after adopt and restart both reported success.
+Both cheap answers lie. `X-Carlos-Version` is stamped by the edge
+(`edge.go:1336–1341`) from the **route row**, so adoption sets it while the old
+process keeps serving. `systemctl is-active` reports "active" for a process
+started from the previous version just as happily — on 2026-08-09 tito said
+"active" while `/proc` said 9668d30, seconds after adopt and restart both
+reported success.
-A `carlos deploy -app console` that waited on `X-Carlos-Version` would be
-**less trustworthy than the bash it retires**. So the console reports its own
-build, from the process, compiled in:
+A `carlos deploy -app console` waiting on `X-Carlos-Version` would be **less
+trustworthy than the bash it retires**. So the console reports its own build,
+from the process, compiled in:
```
GET /healthz → ok <build> # today: "ok"
```
-This is at least as strong as the `/proc` check and arguably stronger: `/proc`
-proves which *path* is executing, this proves which *code* is, since the value
-comes from `internal/version.Build` set by ldflags in the same build that
-produced the binary. It cannot be stamped by adoption, because nothing but the
+At least as strong as the `/proc` check and arguably stronger: `/proc` proves
+which *path* is executing, this proves which *code* is, from
+`internal/version.Build` set by ldflags in the same build. Nothing but the
running process can answer it.
-`/healthz` is served by the console's own mux (`server.go`), not by the edge,
-so the answer is the process's. It is unauthenticated and already public; a
-build id is not a secret (the same value is already on every response as
-`X-Carlos-Version`, and in `BuiltBy` on ship responses).
+`/healthz` is served by the console's own mux (`server.go:794`), not the edge.
+Existing consumers (`internal/vet`, the edge health probe) check **status
+only**, so appending the build is additive-safe. The build id is not a secret —
+`X-Carlos-Version` already exposes it on every response.
-**`carlos deploy` gains a verification mode, not a special case for one app.**
-Today the watch (`deploy.go`, the `X-Carlos-Version` loop) has one exit
-criterion. It gains a second: when the app declares a **build probe** — a path
-and the expectation that the response body carries the build id — the watch
-polls that instead of the header. The console is the first user. Any tenant
-whose app can report its own build gets the same honesty, which is the point:
-this is a tenant feature we need first.
+### 3a. Where the probe is declared, and who parses it
-The probe is declared on the app, alongside the health probe the instance API
-already carries (`{"path":"/healthz","status":200}` — `apiinstances.go`), not
-hard-coded to the console:
+`carlos deploy`'s watch gains a second exit criterion: when the app declares a
+**build probe**, poll it instead of the header.
```json
{"build_probe": {"path": "/healthz"}}
```
-Deliberately minimal. The **path** is the only thing that varies; the contract
-is fixed: a 2xx whose body contains the build id as a whitespace-delimited
-token. Not a regex, not a JSON pointer, not a header name — every one of those
-is a knob whose wrong setting produces a watch that passes while reporting
-nothing, which is the exact failure this section exists to remove. An app that
-cannot meet that contract declares no probe and keeps the header behaviour.
-
-Matching on a whitespace-delimited token rather than a substring is
-load-bearing: a substring match would let build `abc123` satisfy a watch
-waiting for `abc1234`, and short git shas make that a real collision, not a
-theoretical one.
+The **path** is the only thing that varies. The contract is fixed: a 2xx whose
+body contains the build id as a **whitespace-delimited token**. Not a regex,
+not a JSON pointer, not a header name — each is a knob whose wrong setting
+produces a watch that passes while proving nothing. Token-matching rather than
+substring is load-bearing: a substring match lets build `abc123` satisfy a
+watch waiting for `abc1234`, and short git shas make that a real collision.
+
+**Storage, and the consumer list** — CLAUDE.md's rule is that when a storage
+shape changes, the consumer list is everything that PARSES the shape:
+
+The probe is **app-level, not per-instance**. The health probe it resembles
+lives in `provision.Record` (`internal/provision/provision.go:33`, wire key
+`"probe"`, at `apps/<sqid>/<app>/instances/<host>.json`) and that is the wrong
+home for this: `handleAPIInstanceUpdate` is a **read-modify-write** of the
+record, so an older console rewriting a newer record would silently drop the
+field. The build probe describes the *app*, not one instance of it, and every
+instance would carry an identical copy.
+
+It therefore lives on the **app row**, served to the CLI on the existing
+per-app API the deploy path already calls, and is not a bucket object at all.
+Parsers: the app row's own query (which names columns explicitly — verified, no
+`SELECT *`), the API handler that serves it, and `carlos deploy`'s watch. Three,
+all in-repo, none of them a wire shape a box parses. That is the point of
+choosing the row over the record.
+
+### 3b. Two ways the watch can still lie, and what closes them
+
+**The ldflags trap.** `carlos deploy` ships a binary the *operator* built.
+`-version` defaults to `gitShortHead()` (`deploy.go:52`) but `version.Build`
+comes from ldflags the operator must remember (default `"dev"`,
+`internal/version/version.go:6`). Build without the flag and the roll
+**succeeds** while the probe reports `ok dev` forever — deploy reports failure
+on a healthy deploy, the exact inverse of the lie §3 exists to kill. Closed
+twice: §5's ship-time refusal when the embedded build ≠ `-version`, and a
+watch timeout that **names what the probe actually said** (a body of `dev` is
+then self-diagnosing).
+
+**The edge cache.** `watchVersion` cache-busts every poll because the edge
+caches proxied 2xx responses up to five minutes (`deploy.go:1013–1022`,
+`edge.go:933`). The console's `/healthz` sends no `Cache-Control` so it is safe
+today, but §3 is a tenant feature and a tenant's probe path may not be. The
+probe leg inherits the buster, and reads the body through the default transport
+so gzip is transparently decoded.
**Test:** the watch reports live only once the probe body carries the shipped
-build; a probe that keeps answering the previous build times out with a
-diagnosis naming both builds; an app with no probe declared keeps today's
-header behaviour byte for byte.
+build as a whole token; `abc123` does not satisfy a watch for `abc1234`; a
+probe stuck on the previous build times out with a diagnosis naming both the
+expected build and the body received; an app with no probe keeps today's header
+behaviour byte for byte; the probe leg sends a cache-buster.
-## 4. Restart and rollback — already built, needs stamping
+## 4. Restart, rollback, and the recovery hole
-`makeRestartRoute` (`internal/host/agent.go`) arm 1 covers this completely, and
-the reasoning is worth not re-deriving: a route that names its unit is
-try-restarted by name, and **arm 1 returns the failure** rather than swallowing
-it, so `adoptHost` rolls the symlink back to the version that IS running, skips
-`SetVersions`, and leaves the header honest on the old version. A console that
-will not come back cannot produce a lying stamp.
+`makeRestartRoute` arm 1 (`internal/host/agent.go:2041–2050`) try-restarts a
+route that names its unit and **returns** the failure, so `adoptHost` poisons
+the build, repoints the symlink back and skips `SetVersions`
+(`adopt.go:779–818`). That much is built, and it is what makes a lying header
+impossible *in that case*.
-Nothing to build. What is missing is **fleet state**: only one of the console's
-four routes is stamped.
+**It is a much narrower guarantee than it looks, and an earlier draft of this
+spec overclaimed it.** Two holes, both verified.
-```
-platform.carlosframework.com | console | canary/console | (no unit)
-console.bab.oncarlos.com | console | (alias) | (no unit)
-dashboard.carloku.com | console | canary/console | (no unit)
-console.carloku.com | console | canary/console | console.service
-```
+### 4a. The guarantee only covers systemd-visible start failure
+
+`systemctl try-restart` on a `Type=simple` unit succeeds the moment the process
+is exec'd. A console that starts and then panics on its first request, exits
+after a half-applied migration, or listens and 500s, **exits 0**. Then
+`SetVersions` stamps the new build fleet-wide, the §3 probe correctly times
+out — and nothing rolls back. The symlink stays on the broken build, the next
+pass early-returns on `cur == ver` (`adopt.go:637`), and the repair door is the
+thing that is down.
+
+**Requirement:** `console.service` gains a **readiness gate** so that "started
+but not serving" becomes a failed start. `Type=notify` with the console
+signalling readiness *after* migrations have applied and the listener is up is
+the precise form; an `ExecStartPost` readiness poll is the cheap form.
-### 4a. One process, four hostnames
+**This is a pinfra change plus a `cmd/console` change**, and it is a
+cross-repo dependency this spec owns. It cannot be assumed: `console.service`
+is defined in pinfra, not in `internal/host/units/`, and its current `Type=` is
+not recorded anywhere in this repo. Step 0 reads it.
-This is the one place the app model genuinely strains, and it needs a ruling
-rather than a silent choice. Adoption is **per host**: four routes mean four
-`adoptHost` calls. Stamping all four with `console.service` would try-restart
-the same unit four times in one pass — four connection drops for one deploy.
-Leaving three unstamped means three rows stamp `Version` (and therefore
-`X-Carlos-Version`) with no restart behind them.
+The residual after this — a console that starts, serves, passes readiness and
+is *behaviourally* wrong — has no automatic recovery and is break-glass
+(§9). Say so out loud rather than implying the rollback covers it.
-**Ruling: leave exactly one route stamped, and stop relying on the header.**
-One stamped route is sufficient to cycle the process, which is the only thing
-that has to happen. The three unstamped rows' headers become cosmetic — and
-harmless, because §3 moves the exit criterion off the header and onto the
-process's own answer. `console.bab.oncarlos.com` is an alias and correctly
-carries no version at all (a minted alias serves 200 with no
-`X-Carlos-Version` by design).
+### 4b. Rollback repoints the symlink but leaves the console DOWN — and the next pass then lies
-The alternative — teaching adoption to dedupe restarts by unit within a pass —
-is a real improvement and explicitly **out of scope here**. It should be filed:
-it is the general fix for any bespoke service behind several hostnames, and
-this slice does not need it.
+This is the more serious of the two and it is **not** in the review that
+prompted this rework; it was found verifying that review.
-The stamped route is `console.carloku.com` (already stamped, and the canonical
-console host since 2026-08-12). `carlos deploy -app console -host
-console.carloku.com` scopes the watch to it; `-host` already exists.
+On restart failure `adoptHost` repoints the symlink to `cur` and returns
+(`adopt.go:781–815`). It does **not** start the unit again. For a `try-restart`
+that failed to bring the service back, systemd leaves it **failed** — so the
+box is left with a correct symlink and no console.
-## 5. Server-side arch refusal
+It gets worse on the next pass. `try-restart` is by definition a **no-op on a
+unit that is not running** (the property `makeRestartRoute`'s comment relies on
+so that a deliberately-stopped console stays stopped). So:
-Failure mode #1 in the table above — an amd64 binary on a Graviton box — is
-covered on the happy path by `-target linux-arm64` being `carlos deploy`'s
-default. That guard only protects callers who use the flag correctly.
+```
+pass 1: PointLive(new) → try-restart → FAILS → poison, PointLive(old), no stamp
+ unit: failed. console: DOWN. header: honest (old).
+pass 2: PointLive(new) → try-restart on a NOT-RUNNING unit → no-op, exit 0
+ → SetVersions(new)
+ unit: still failed. console: STILL DOWN. header: claims the new build.
+```
-For an app flagged as control-plane (§6), the ship door additionally **reads
-the artifact's ELF header** and refuses a machine type that does not match the
-declared target. This closes the path a hand-run `carlos ship`, a CI job or a
-future script could still take into the same channel.
+The second pass converts an honest failure into the exact lie the whole
+mechanism exists to prevent, over a console that is not running at all.
-Scoped to control-plane apps deliberately: a tenant may have entirely
-legitimate reasons to ship an artifact whose ELF machine we would guess wrong
-about, and refusing their ship on our inference is not our call. For the app
-that *is* the control plane, on boxes we own and know are arm64, it is.
+**Requirement:** when arm 1's try-restart fails and the symlink has been rolled
+back, adoption must **start** the named unit on the restored version — a
+`systemctl start`, not a try-restart, because the whole point is that the unit
+is down. If that start also fails, the version stays poisoned and unstamped and
+the failure is printed; the box is then genuinely broken and break-glass is
+correct.
-**Test:** an amd64 ELF shipped to a control-plane app with `-target
-linux-arm64` is refused, naming both the declared target and what was found; a
-matching artifact ships; a non-control-plane app is unaffected; a non-ELF
-artifact (a static site tarball) is unaffected.
+This is a **tenant** fix. Any bespoke unit-backed route has this hole today.
+It is the strongest argument in the whole slice for making the platform eat its
+own food: the hole has presumably been there since #110 and nobody found it,
+because the pinfra script's explicit post-pass `systemctl restart` has been
+papering over it on the only unit-stamped route in the fleet.
-## 6. The control-plane flag
+**Test:** a failed named-unit restart leaves the unit RUNNING on the previous
+version, the symlink on the previous version, and the row unstamped; a second
+pass over the same still-broken build does not stamp; a failed rollback start
+is reported and still does not stamp.
-Replacing the console is not the same act as replacing `platform-site`. That
-belongs on the **app**, not on a verb:
+### 4c. One process, several hostnames — the stamped route is not a free choice
-```
-apps.control_plane BOOLEAN NOT NULL DEFAULT 0
-```
+The console is one `console.service` behind several hostnames. Adoption is
+**per host** (`reg.List()`, `ORDER BY host`, `registry.go:604`), and each
+`adoptHost` repoints `live/<that host>` (`PointLive(optDir, rt.Host, …)`) and
+*then* fires the restart callback.
-Additive with a default that means what every existing row already means — the
-idiom the `kind` and service-principal columns used.
+So the restart fires inside **one** host's adoption, while the other hosts'
+symlinks have not moved yet. Which means:
-An app with the flag set:
+> **The route that must carry the `Unit` stamp is the host whose symlink the
+> unit's `ExecStart` resolves — not the canonical hostname, not the first
+> alphabetically.**
-- requires **fresh auth** to promote, exactly as `stable` already does
- (`apicli.go` answers `403` with `console_url` and the CLI sends the operator
- to finish in the browser). The blast radius, not the rung, is what is being
- gated, so it applies at every rung.
-- gets the §5 arch refusal on ship.
+Flagship today gets this **wrong**, and the pinfra script has been hiding it:
-Why a flag and not a verb: a verb protects only the path that goes through the
-verb. A flag on the row also protects a hand-run `carlos promote -app console`,
-a service principal, a CI job and the console's own app page. The gate should
-sit where the danger is, which is the app.
+```
+ExecStart = /opt/carlos/live/platform.carlosframework.com serve
+stamped = console.carloku.com ← sorts BEFORE platform.…
+```
-Set on `ops/console` in both deployments. Nothing else, for now.
+The pass restarts at `console.carloku.com` (2nd of 4 by host order), when
+`live/platform.carlosframework.com` still points at the **old** build; that
+symlink moves last, under an already-running process. Every roll to date has
+worked only because `roll-console.sh` runs an explicit `systemctl restart
+console` **after** the whole pass. Remove that line — which is precisely what
+"the console is an ordinary app" means — and the console keeps serving the old
+build while all its routes advertise the new one.
+
+**Requirement:** step 0 verifies, per box, that the `Unit`-stamped route is the
+host named in `ExecStart`, and restamps if not. Exactly one route is stamped:
+one is sufficient to cycle the process, and stamping several would try-restart
+the same unit several times in one pass.
+
+The unstamped rows' `X-Carlos-Version` then becomes cosmetic, which §3 makes
+harmless. One honesty note: on restart failure the poison map only stops
+**later** hosts in the pass from stamping (`adopt.go:648`), so whether the
+cosmetic headers stay honest depends on alphabetical order relative to the
+stamped host — protection by accident, not by design. The real fix is the
+follow-up below.
+
+**Follow-up to file, explicitly out of scope:** teach the pass to collect
+named-unit restarts and run them **once, after every host's symlink has
+moved**. That makes the stamp/ExecStart coupling and the alphabetical accident
+both irrelevant, and it is the general fix for any bespoke service behind
+several hostnames.
+
+## 5. Ship-time refusals for a control-plane app
+
+Two refusals, both at the ship door, both scoped to apps carrying the §6 flag.
+
+**Architecture.** Read the artifact's ELF header and refuse a machine type that
+does not match the declared `-target`. `-target linux-arm64` being `carlos
+deploy`'s default only protects the careful caller; this closes the path a
+hand-run `carlos ship`, a CI job or a future script takes into the same channel.
+
+**Embedded build.** Refuse a binary whose embedded `internal/version.Build`
+does not equal the `-version` being shipped. This closes §3b's ldflags trap at
+the door instead of at a timeout, and it incidentally closes failure mode #7:
+the root `carlos` binary and `cmd/console` stamp the same `version.Build`, so
+this does not catch a wrong-artifact ship on its own — but combined with the
+probe (only `cmd/console` serves `/healthz`) the pair does.
+
+Scoped to control-plane apps deliberately: a tenant may legitimately ship an
+artifact we would guess wrong about, and refusing their ship on our inference
+is not our call. For the app that *is* the control plane, on boxes we own and
+know are arm64, it is.
+
+**Test:** an amd64 ELF with `-target linux-arm64` is refused naming both the
+declared target and what was found; a binary whose embedded build ≠ `-version`
+is refused naming both; a matching artifact ships; a non-control-plane app is
+unaffected; a non-ELF artifact (a static tarball) is unaffected.
+
+## 6. The control-plane flag gates DESTRUCTION, not promotion
+
+```sql
+ALTER TABLE apps ADD COLUMN control_plane INTEGER NOT NULL DEFAULT 0;
+```
-**Test:** promoting a control-plane app without fresh auth is refused with the
-existing `stable`-shaped 403; with fresh auth it succeeds; an ordinary app at a
-non-stable rung is unaffected; the refusal does not leak the flag's existence
-to a non-member (it sits behind the same 404).
+An earlier draft had this flag demand fresh auth to **promote**, "exactly as
+stable already does, at every rung." That was wrong, and wrong in a way that
+defeated the spec's own purpose. `stable`'s API gate is a bare 403 with a
+`console_url` and **no API-side step-up** (`apicli.go:632–638`), and
+`carlos deploy` `log.Fatalf`s on a failed promote *after the ship has already
+landed* (`deploy.go:206–208`). Extending it to every rung would mean
+`carlos deploy -app console` ships the artifact and then dies, every time —
+the exact command this spec exists to create.
+
+**Invert it.** Deploying the console should be easy; destroying it should be
+hard. That is also where the real danger is: `handlePurgeApp` (`ownership.go:293`)
+can delete `apps/bab/console/**` — the release history the fleet is *currently
+running* — behind an owner check, a typed name and fresh auth, none of which
+knows this app is the control plane.
+
+An app carrying the flag:
+
+- **refuses trash, purge, rename and transfer outright.** Not "with fresh
+ auth" — refused, with a message saying the flag must be cleared first. The
+ console's release history is not something to delete behind a confirmation
+ dialog.
+- **refuses promotion by a service principal.** A person with ops membership
+ can roll the console; a CI credential cannot. This closes the hole §10 step
+ ordering would otherwise leave open, without a step-up flow.
+- gets §5's two ship-time refusals.
+
+Promotion by a **person** is gated by ops membership, which is already the
+operator boundary, and by nothing else. That is what keeps
+`carlos deploy -app console` working as an ordinary command.
+
+**How the flag is set.** Not by a box-local sqlite write — that contradicts the
+no-box-commands corollary. It is set at claim time: `carlos apps create` gains
+`-control-plane`, owner-only, and the console's app settings page can clear it
+(clearing is the dangerous direction, so clearing requires fresh auth; setting
+does not).
+
+**Test:** trash/purge/rename/transfer on a flagged app are refused naming the
+flag; a service principal's promote is refused; a person's promote at any rung
+succeeds with ordinary membership; clearing the flag requires fresh auth;
+setting it requires owner; an unflagged app is unaffected in every case; the
+refusal sits behind the existing 404 so it does not leak the flag's existence
+to a non-member.
## 7. The version stamp and the dirty tree
`roll-console.sh` refuses a dirty tree because the version stamp is the git
-short sha, and a stamp naming a commit the binary does not match is exactly how
-a false "verified" happens. `carlos deploy` already defaults `-version` to
-`gitShortHead()`, so it inherits the convention — but not the guard.
+short sha, and a stamp naming a commit the binary does not match is how a false
+"verified" happens. `carlos deploy` already defaults `-version` to
+`gitShortHead()` and inherits the convention — but not the guard.
+
+`carlos deploy` refuses to ship when `-version` was **derived from git** and
+the tree is dirty. An explicit `-version` is the operator saying they mean it,
+and is not second-guessed.
-`carlos deploy` refuses to ship when `-version` was **derived from git** (not
-passed explicitly) and the tree is dirty. An explicit `-version` is the
-operator saying they mean it, and is not second-guessed.
+A general fix: any tenant using the default stamp today can ship a binary whose
+sha names a commit it was not built from.
-This is a general fix, not a console one: any tenant using the default stamp
-today can ship a binary whose sha names a commit it was not built from.
+**Test:** dirty tree + derived version refuses before anything ships; dirty
+tree + explicit `-version` ships; clean tree ships.
-**Test:** a dirty tree with a derived version refuses before anything ships; a
-dirty tree with an explicit `-version` ships; a clean tree ships.
+## 8. The migration has an ordering trap
-## 8. What happens to the pinfra scripts
+`store.go`'s `migrate()` runs its `ALTER TABLE apps` block (lines 198–219)
+**before** `migrateAppsKey()` (line 231), whose rebuild copies an **enumerated
+column list** into `apps_new` (271–288) — and the code already carries a
+comment about this hazard at line 280.
-`roll-console.sh` and `verify-console.sh` **stay**, reclassified in their own
+Add `control_plane` to that ALTER block and any database that has not yet been
+through the rebuild — a self-hoster jumping versions — has the column
+**silently dropped**, after which every `SELECT control_plane` errors.
+
+**Requirement:** either add the ALTER *after* `migrateAppsKey()`, or add the
+column to `apps_new` and its copy list. The plan picks one and tests the
+jumping-a-version path explicitly.
+
+Otherwise the column is safe: every apps query names its columns (verified —
+no `SELECT *`), so no row parser breaks.
+
+## 9. The pinfra scripts stay
+
+`roll-console.sh` and `verify-console.sh` **stay**, reclassified in their
header comments as **break-glass**: the path that works when the console is
down. That is the honest answer to the bootstrap question — the console
-mediates its own replacement on the happy path, and §4's automatic rollback
-covers a build that will not start, but a console that is *already* down cannot
-be the door to its own repair.
+mediates its own replacement on the happy path, §4 covers a build that fails
+readiness, and §4a's residual (starts, serves, behaves wrong) has no automatic
+recovery at all. A console that is already down cannot be the door to its own
+repair.
`ship-app.sh` and `promote-app.sh` lose their console/kass/keymail/
-platform-site callers. They are not deleted — `promote-app.sh` still serves
+platform-site callers but are not deleted; `promote-app.sh` still serves
direct-bucket promotes for apps outside any console's knowledge.
-## 9. Order of work
-
-Each step leaves the estate working.
-
-0. **Check** `HasOwners("console")` on both deployments, and confirm no
- `apps/console/` legacy prefix on either. If either is true, §2's exemption
- is not sufficient and the plan needs a step before this one.
-1. §2 — the #79 owner exemption, with tests.
-2. Claim `ops/console` on both deployments. Verify existing release history is
- visible through `carlos channels -app console -account ops`. **Nothing is
- deployed yet**; this is a read-only change in visible state.
-3. §3 — `/healthz` carries the build; the app-declared build probe; the watch's
- second exit criterion.
-4. §6 — the flag, the fresh-auth gate, set on `ops/console`.
-5. §5 — the arch refusal.
-6. §7 — the dirty-tree guard.
-7. **First `carlos deploy -app console`** on tito, verified against the build
- probe. Tito first: it is the smaller blast radius and the deployment whose
- console going down does not take carloku with it.
-8. The same on flagship.
-9. Prove `carlos deploy` on `kass`, `keymail`, `platform-site`; update
- CLAUDE.md's "Deploy reality" section, which currently tells the reader to
- use the pinfra scripts for all four.
-10. Reclassify the pinfra scripts as break-glass in their header comments.
-11. File the adoption restart-dedupe follow-up from §4a.
-
-## 10. Open questions
-
-- **Does `ops/console` carry a CLI grant?** Step 0. If it does, `HasOwners`
- refuses the claim before the bucket check is reached and §2 does not help.
-- **Which console host is canonical for the deploy watch?**
- `console.carloku.com` is assumed (§4a) because it is already the stamped one.
- `roll-console.sh`'s `coords()` names `platform.carlosframework.com` as the
- flagship origin, so these two disagree today and the spec picks the stamped
- one. Worth a second look during step 7.
-- **Does the console's unit ExecStart resolve through a per-host `live/<host>`
- symlink, and if so which host's?** Verified only that
- `/proc/<pid>/exe` resolves to `/opt/carlos/versions/<sha>/console`. If the
- unit is pinned to one host's symlink, §4a's ruling is not merely convenient
- but required, and the plan should say so.
+## 10. Order of work
+
+Reordered after review: the flag must exist **before** the claim, and the code
+must reach the boxes **before** the first self-deploy.
+
+**0. Survey both boxes** (read-only, no changes). Per box: `console.service`'s
+ `ExecStart` path and its `Type=`; which route carries the `Unit` stamp;
+ every console route and channel (tito is entirely unsurveyed — it has
+ `console.platform.tito.io` and the still-serving `carlos.tito.io`);
+ `HasOwners("console")`; any legacy `apps/console/` prefix; the bucket
+ production flag on `console`. Flagship's readings are in the appendix;
+ **tito's are not, and step 7 deploys there first.**
+
+1. §2 owner exemption, with the service-principal pin and the `handleNewApp`
+ policy change.
+2. §8 migration + §6 flag, its destructive-verb refusals, its service-principal
+ promote refusal, and `-control-plane` on `apps create`.
+3. §3 `/healthz` carries the build; the app-level build probe; the watch's
+ second exit criterion with cache-busting and a naming timeout.
+4. §4b restart-recovery: start the unit on the restored version after a rolled
+ back adoption. **Tenant fix; lands independently of everything else here.**
+5. §5 ship-time refusals. §7 dirty-tree guard.
+6. **`cmd/console` readiness signalling** (§4a) — the code half of the
+ `Type=notify` change.
+7. **Delivery roll.** One last `roll-console.sh` on both boxes carrying steps
+ 1–6, plus a CLI release carrying the new watch. Without this, step 9 runs
+ against a console that predates its own probe and fails.
+8. **pinfra:** `console.service` gains the readiness gate (§4a). Applied to
+ both boxes.
+9. **Stamp correctly** (§4c): on each box, move the `Unit` stamp to the host
+ named in `ExecStart`, exactly one route. Verify with a no-op adopt.
+10. **Claim `ops/console` with the flag set, in one step**, on both
+ deployments. Not two: a row without the flag is a window in which any ops
+ member or `Publish` service principal can roll the production console.
+11. **First `carlos deploy -app console` on tito**, verified against the build
+ probe. Tito first: smaller blast radius, and its console going down does
+ not take carloku with it.
+12. The same on flagship.
+13. Prove `carlos deploy` on `kass`, `keymail`, `platform-site` — each with
+ step 9's precondition first. `kass` and `keymail` are addr-backed bespoke
+ services whose `Unit` stamps are unsurveyed; an unstamped `keymail` proof
+ run is the #110 shape again (symlink swapped, nothing restarted, header
+ lies) and would "prove" a deploy that did not happen.
+14. Update CLAUDE.md's "Deploy reality", which currently sends the reader to
+ the pinfra scripts for all four apps. Reclassify the scripts as break-glass.
+15. File the two follow-ups: the restart-dedupe pass (§4c), and how a **fresh
+ deployment** gets its `ops/console` row (§11).
+
+## 11. Open questions
+
+- **How does a fresh deployment or a self-hoster get the `ops/console` row?**
+ Nothing creates it — not provisioning, not `materializeQuietly`, not
+ `BackfillOpsMemberships`. Steps 10 backfills our two boxes by hand. The
+ general answer belongs to slice 3 or to #135, and is filed at step 15, but it
+ should be named as an unsolved product gap rather than left implicit.
+- **What alarms on a console that is down?** §4a's residual and §4b's
+ rollback both print into the root pass's journal — box-only. The CLI's
+ timeout tells the operator who ran the deploy and nobody else. Out of scope
+ to build here; named so it is not mistaken for covered.
+- **`carlos deploy -app console` run against the console being deployed** is
+ expected to work — the watch tolerates the restart — but its *diagnostics*
+ leg reads the platform log stream through the console API, so it will error
+ precisely when it is most wanted. Confirm at step 11 and degrade the
+ diagnostics gracefully rather than failing the deploy.
+
+## Appendix — flagship readings, 2026-08-23
+
+```
+apps rows in ops (bab): deploy-probe, platform-site (no console)
+bucket prefixes under apps/bab/: apt-carlosframework, carloku, carlosframework,
+ console, deploy-probe, derp, hello, keymail,
+ meep, platform-site
+owners table (grants): derp, meep, platform-site (no console)
+legacy apps/console/: absent
+
+console routes:
+ platform.carlosframework.com | console | canary/console | (no unit)
+ console.bab.oncarlos.com | console | (alias) | (no unit)
+ dashboard.carloku.com | console | canary/console | (no unit)
+ console.carloku.com | console | canary/console | console.service
+
+console.service ExecStart: /opt/carlos/live/platform.carlosframework.com serve
+console.service Type: not read — step 0
+CONSOLE_ORIGIN: https://console.carloku.com
+CONSOLE_DB: /data/console/console.db
+```
+
+The `ExecStart` host and the stamped route disagree. That is §4c.