# The console is an app — retiring the pinfra roll

**Date:** 2026-08-23 · **Status:** approved (Paul, in-session); reworked after a
Fable review · **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 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
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, 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.

| # | Failure mode | Covered by |
|---|---|---|
| 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

Verified against the live estate on 2026-08-23. See the appendix for the raw
readings.

**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, one route already naming `console.service`.

**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.

**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 no claim.** Contrary to CLAUDE.md's framing
of four equivalent apps, `kass`, `keymail` and `platform-site` are already
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.

```go
// today — always refuses
if len(keys) > 0 { return claimTaken, "" }

// after — unless the claimer owns the account the prefix belongs to
if len(keys) > 0 && !ownerClaim { return claimTaken, "" }
```

`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`.
  The exemption applies **only** to the account-qualified prefix.
- **`HasOwners(name)` still blocks.** A CLI-granted app in `ops` stays
  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, 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

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` 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"
```

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: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.

### 3a. Where the probe is declared, and who parses it

`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"}}
```

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 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, rollback, and the recovery hole

`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*.

**It is a much narrower guarantee than it looks, and an earlier draft of this
spec overclaimed it.** Two holes, both verified.

### 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.

**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.

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.

### 4b. Rollback repoints the symlink but leaves the console DOWN — and the next pass then lies

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.

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.

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:

```
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.
```

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.

**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.

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.

**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.

### 4c. One process, several hostnames — the stamped route is not a free choice

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.

So the restart fires inside **one** host's adoption, while the other hosts'
symlinks have not moved yet. Which means:

> **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.**

Flagship today gets this **wrong**, and the pinfra script has been hiding it:

```
ExecStart = /opt/carlos/live/platform.carlosframework.com serve
stamped   = console.carloku.com          ← sorts BEFORE platform.…
```

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 — and narrows a service principal's authority

```sql
ALTER TABLE apps ADD COLUMN control_plane INTEGER NOT NULL DEFAULT 0;
```

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.
- **refuses an instance-record DELETE outright** (added mid-rollout, 2026-08-28).
  This is the eighth door and the shortest fuse of the set. `carlos deploy`
  resolves hosts through `apps/<sqid>/<app>/instances/<host>.json`, so making
  the console deployable means giving it a record — and a record is an
  authority transfer: once it exists and the route is `Provisioned`, the
  provisioning reconciler's removal phase retires the route the moment the
  record goes. `DELETE /api/cli/apps/{account}/{app}/instances/{host}` is
  `svcrole.Allow(svcrole.Instance)`, so one call from a person *or* a CI
  credential holding that role took the control plane off the air. Same class
  as trash and purge; same refusal. **The console must not become
  instance-backed until this guard is in place.**
- **refuses an instance CREATE or PATCH by a service principal.** A person must
  be able to write the console's record — that is the runbook step the whole
  §10 sequence turns on — but a CI credential must not acquire the reconciler's
  authority over the console's route, and PATCH is a repoint: the reconciler
  converges a route's Channel and Environment *from* this record on every pass.
  Same person/service split as promote, for the same reason.
- **refuses a domain DETACH outright, and a domain ATTACH by a service
  principal.** `DELETE /api/cli/apps/{account}/{app}/domains/{host}`'s
  first-route branch calls the same `provision.DeleteRecord` the instance
  delete does, and `POST .../domains`'s first-route branch *writes* a declared
  record — both registered `svcrole.Allow(svcrole.Publish, svcrole.Instance)`,
  so a service credential reaches either. Guarding the instance family and
  leaving these open would be a guard with a published way round it. Detach's
  browser twin (`detachDomain`) carries the same outright refusal; attach's
  needs none, because a browser session's principal is always a person.
  Neither guard is conditional on the record-touching branch: a conditional
  would have to re-derive "does this claim mint the app's route" ahead of the
  code that derives it, and the two would drift.
- gets §5's two ship-time refusals.

**The closure principle — read this before adding a tenth door.** The set above
is not a list to extend by taste; it is the **`provision.Record` lifecycle**,
closed. A record is what grants the provisioning reconciler authority over a
route, so every door that CREATES, MUTATES or REMOVES one for a flagged app
carries a guard: create (`POST .../instances`, `POST .../domains`) and mutate
(`PATCH .../instances/{host}`) refuse a **service principal**, because a person
performing them is a legitimate operator act; remove
(`DELETE .../instances/{host}`, `DELETE .../domains/{host}`) refuses
**outright**, because removal is destruction and belongs with trash and purge.
A future door that touches that lifecycle needs the same guard by the same
argument, and one that does not, does not.

Two lifecycle sites carry no `control_plane` call and are still covered, which
is worth writing down so the next audit does not read them as gaps.
`POST .../instances/{host}/channel` (`channelmove.go`) MUTATES a record, and is
registered with the **zero `svcrole.Set{}`** — no service principal reaches it
at all, admin included, which enforces the service half more strictly than a
refusal would; a person repointing the control plane's channel is the same
legitimate operator act as promoting it. And `rollbackAttach`
(`domainattach.go`) deletes a record, but it is not a door: it is the
compensating delete inside the attach path, reachable only once that path's own
guard has passed, and it removes only the record the same call just tried to
write — guarding it would strand half-written attaches instead of protecting
anything.

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; the instance-record DELETE is refused for a person AND for a service, and
the record survives; a domain detach is refused for a person and a service at
the API door and for the owner at the browser door, and the claim survives; a
service principal's promote, retention change, instance create, instance patch
and domain attach are each refused — the attach in BOTH request shapes, alias
and first-route, since the guard is not conditional on the branch — and each
for both service roles the domains routes admit; a person's promote at any rung succeeds with ordinary
membership, and a person can still CREATE and PATCH the flagged app's instance
record and ATTACH a domain to it — asserted on the stored record and the stored
claim, not on a status code, because that half is the runbook step the flag must
not break; on every service-refused and outright-refused door, clearing the flag
lets the IDENTICAL call through, which is what proves the flag is the only
variable; clearing the flag requires fresh
auth; setting it requires owner; an unflagged app is unaffected in every case;
and every refusal sits behind the existing 404, so a non-member probing any of
these doors gets the byte-identical not-found and never learns the flag's
existence.

## 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 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.

A general fix: 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.

## 8. The migration has an ordering trap

`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.

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, §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 but are not deleted; `promote-app.sh` still serves
direct-bucket promotes for apps outside any console's knowledge.

## 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.
