spec: the console is an app — retiring the pinfra roll
Slice 1 of the "platform deploys like a tenant" work. The console is already structurally an app — releases at apps/bab/console/ on canary/console, adopted by the same pass, served through routes, one route already naming console.service. What it is not is an app RECORD, because ship-app.sh ships direct-to-bucket and direct mode skips the claim. So the console API cannot see it, and #79 refuses the claim precisely because the objects exist. The spec makes it an ordinary app deployed by the ordinary verb, rather than adding `carlos system deploy`. A separate verb is where our own deficiencies hide: the header that lies, the restart nobody trusts, the rollback verified by hand are all deficiencies TENANTS have, and while the platform team has its own door nothing forces us to feel them. Three of the four fixes here (honest build verification, the dirty-tree guard, the arch refusal) are tenant features we need first. carlos system is deferred, not rejected: it earns its place for the root binary, edge, activator and provisioning reconciler, which have no route and no channel and cannot be apps. The line: `system` is for what is not an app. Contents: the #79 owner exemption (an owner claiming under their own account's prefix is adopting their own objects); a build probe the process answers itself, since X-Carlos-Version is stamped by adoption and lies; the control-plane flag on the app row rather than a verb, so it also covers a hand-run promote; a server-side ELF arch refusal; and the dirty-tree version guard. Restart and rollback need no code — arm 1 of makeRestartRoute already returns the failure so adoptHost rolls back — but they need the fleet's routes stamped, and §4a rules on the one place the app model strains: one console process behind four hostnames. Also corrects CLAUDE.md's framing of four equivalent pinfra apps. kass, keymail and platform-site are already claimed rows with live channels; console is the only #79 case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+368
−0
docs/superpowers/specs/2026-08-23-console-as-an-app-design.md+368 −0
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| new file mode 100644 |
| index 000000000..a3bf61bb7 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-23-console-as-an-app-design.md |
| @@ -0,0 +1,368 @@ |
| +# The console is an app — retiring the pinfra roll |
| + |
| +**Date:** 2026-08-23 · **Status:** approved (Paul, in-session) · **Slice:** 1 of 4 |
| + |
| +## Problem |
| + |
| +Every tenant deploys with one command and no infrastructure access. The |
| +platform deploys itself with a bash script, an AWS profile, an SSM session and |
| +a checkout of a second repository. |
| + |
| +``` |
| +tenant: carlos deploy -app blog |
| +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. |
| + |
| +This slice makes the console an ordinary app, deployed by the ordinary verb. |
| + |
| +### Not in this slice |
| + |
| +`carlos system` is **deferred, not rejected** (Paul, 2026-08-23). It earns its |
| +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. |
| + |
| +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.** |
| + |
| +## The test the design must pass |
| + |
| +`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: |
| + |
| +| # | Failure mode | Where it is handled after this slice | |
| +|---|---|---| |
| +| 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 | |
| +| 6 | sysusers before tmpfiles | Not reached — root-binary concern, slice 3 | |
| + |
| +## 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. |
| + |
| +**The console is already structurally an app.** Its releases are 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`. |
| + |
| +**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 |
| +`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. |
| + |
| +**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`. |
| + |
| +**The other three pinfra apps need nothing.** 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. |
| + |
| +## 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: |
| + |
| +```go |
| +// today — always refuses |
| +if len(keys) > 0 { return claimTaken, "" } |
| + |
| +// after — refuses 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: |
| + |
| +- **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.) |
| +- **`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. |
| + |
| +**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 |
| +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. |
| + |
| +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: |
| + |
| +``` |
| +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 |
| +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). |
| + |
| +**`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. |
| + |
| +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: |
| + |
| +```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. |
| + |
| +**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. |
| + |
| +## 4. Restart and rollback — already built, needs stamping |
| + |
| +`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. |
| + |
| +Nothing to build. What is missing is **fleet state**: only one of the console's |
| +four routes is stamped. |
| + |
| +``` |
| +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. One process, four hostnames |
| + |
| +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. |
| + |
| +**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). |
| + |
| +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. |
| + |
| +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. |
| + |
| +## 5. Server-side arch refusal |
| + |
| +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. |
| + |
| +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. |
| + |
| +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. |
| + |
| +**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. |
| + |
| +## 6. The control-plane flag |
| + |
| +Replacing the console is not the same act as replacing `platform-site`. That |
| +belongs on the **app**, not on a verb: |
| + |
| +``` |
| +apps.control_plane BOOLEAN NOT NULL DEFAULT 0 |
| +``` |
| + |
| +Additive with a default that means what every existing row already means — the |
| +idiom the `kind` and service-principal columns used. |
| + |
| +An app with the flag set: |
| + |
| +- 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. |
| + |
| +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. |
| + |
| +Set on `ops/console` in both deployments. Nothing else, for now. |
| + |
| +**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). |
| + |
| +## 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. |
| + |
| +`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. |
| + |
| +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:** a dirty tree with a derived version refuses before anything ships; a |
| +dirty tree with an explicit `-version` ships; a clean tree ships. |
| + |
| +## 8. What happens to the pinfra scripts |
| + |
| +`roll-console.sh` and `verify-console.sh` **stay**, reclassified in their own |
| +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. |
| + |
| +`ship-app.sh` and `promote-app.sh` lose their console/kass/keymail/ |
| +platform-site callers. They 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. |