carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# The console is an app — retiring the pinfra roll
2
3**Date:** 2026-08-23 · **Status:** approved (Paul, in-session); reworked after a
4Fable review · **Slice:** 1 of 4
5
6## Problem
7
8Every tenant deploys with one command and no infrastructure access. The
9platform deploys itself with a bash script, an AWS profile, an SSM session and
10a checkout of a second repository.
11
12```
13tenant: carlos deploy -app blog
14platform: roll-console.sh <platform-checkout> both # pinfra + AWS + SSM
15```
16
17That inversion is the thing CLAUDE.md's own rule complains about ("every
18`carlos` command is designed for a user with no access to the underlying
19infra"), and it costs more than convenience: a special door is where our own
20deficiencies hide. Everything that makes rolling the console feel dangerous — a
21version header that lies, a restart nobody trusts, a rollback verified by hand
22— is a deficiency **tenants have too**. They just complain less. While the
23platform team has its own door, nothing forces us to feel it.
24
25This slice makes the console an ordinary app, deployed by the ordinary verb.
26
27Three of the five fixes below (honest build verification §3, the dirty-tree
28guard §7, and the restart-recovery fix §4b) are **tenant** fixes that this work
29merely forces us to make.
30
31### Not in this slice
32
33`carlos system` is **deferred, not rejected** (Paul, 2026-08-23). It earns its
34place for what genuinely cannot be an app: the root `carlos` binary, the edge
35router, the activator, the provisioning reconciler. Those have no route, no
36channel to be adopted from and no upstream — they are the substrate that does
37the adopting and routing. `update-platform-binary.sh` is 85 lines of exactly
38that work, none of which has an app-shaped equivalent. That is slice 3.
39
40The dividing line, stated once so later work can apply it: **`carlos system` is
41for what is not an app. Anything adoptable through a route is an app.**
42
43## The test the design must pass
44
45`roll-console.sh` and `verify-console.sh` encode failure modes this project has
46already paid for. A replacement that drops any of them is a regression wearing
47better clothes.
48
49| # | Failure mode | Covered by |
50|---|---|---|
51| 1 | amd64 artifact shipped to Graviton boxes | §5 ELF refusal (`-target` default only helps the careful caller) |
52| 2 | Adoption swaps the symlink; the old process keeps serving | §4a — the *right* route stamped |
53| 3 | SSM shells missing `set -a; source host.env` | No SSM at all |
54| 4 | "Verified" reported from a stale binary | §3 build probe |
55| 5 | A dirty tree stamping a version naming no real commit | §7 |
56| 6 | sysusers before tmpfiles | Not reached — root-binary concern, slice 3 |
57| 7 | **The console artifact is `cmd/console`, not the root binary** (8-minute outage, 2026-08-08) | §5 embedded-build refusal |
58
59## 1. What is actually in the way
60
61Verified against the live estate on 2026-08-23. See the appendix for the raw
62readings.
63
64**The console is already structurally an app.** Releases at
65`apps/bab/console/`, properly account-qualified under `ops`, on channel
66`canary/console`, adopted by the same pass as every tenant, served through
67routes, one route already naming `console.service`.
68
69**But it is not an app *record*.** `ops` on flagship holds two app rows:
70`deploy-probe` and `platform-site`. There is no `console` row, because
71`ship-app.sh` ships direct-to-bucket and direct mode deliberately skips the
72claim. Without a row the console API cannot see the app at all.
73
74**Claiming the name is refused today** by `claimApp`
75(`internal/console/server.go:2426`) because the prefix has objects — issue
76**#79**.
77
78**The other three pinfra apps need no claim.** Contrary to CLAUDE.md's framing
79of four equivalent apps, `kass`, `keymail` and `platform-site` are already
80claimed rows with live channels. They are on `ship-app.sh` out of habit.
81
82## 2. Claiming an app whose objects already exist (#79)
83
84`claimApp`'s bucket guard is right for a stranger and wrong for the account
85that owns the prefix. `apps/<sqid>/<app>/` is namespaced **by account**, so
86objects under `apps/bab/` can only be `ops`'s. An owner of `bab` claiming a
87name under `bab`'s own prefix is adopting their own objects.
88
89```go
90// today — always refuses
91if len(keys) > 0 { return claimTaken, "" }
92
93// after — unless the claimer owns the account the prefix belongs to
94if len(keys) > 0 && !ownerClaim { return claimTaken, "" }
95```
96
97`ownerClaim` is passed by the caller. Five constraints, each of which a naive
98implementation gets wrong:
99
100- **Owner, not member.** Adopting existing release history is an ownership act.
101- **`ownerClaim` is FALSE for every service principal.** A service has no role
102 at all, and an account-scoped `PrincipalService` with `App == ""`
103 legitimately reaches `claimApp` (`apicli.go:346–350` blocks only app-scoped
104 ones). Without an explicit pin, the exemption silently hands every CI
105 credential the power to adopt release history — exactly the widening the
106 svcrole work exists to prevent.
107- **`handleNewApp` (the browser door) must be changed deliberately, not
108 incidentally.** It currently *discards* the role
109 (`addr, raw, acct, _, ok := s.account(…)`, `server.go:2524`) and its doc
110 comment says "Any member may claim." The exemption changes that door's stated
111 policy; the comment changes with it.
112- **The legacy prefix still blocks, always.** For `ops`, `claimApp` also checks
113 `release.LegacyAppPrefix(name)` — `apps/<app>/`, unqualified. Those objects
114 predate account scoping and are owed an `accounts migrate -from legacy`.
115 The exemption applies **only** to the account-qualified prefix.
116- **`HasOwners(name)` still blocks.** A CLI-granted app in `ops` stays
117 claim-proof.
118
119**Refusals stay byte-identical for non-members.** The new branch runs after the
120owner gate, so only someone who has already proved ownership can reach it.
121
122**What this does and does not unblock.** Verified: the `owners` table holds
123grants for `derp`, `meep` and `platform-site` only. So the exemption unblocks
124`console`, `carloku`, `carlosframework`, `keymail` (the `bab` prefix),
125`hello` and `apt-carlosframework`; `derp` and `meep` stay claim-proof by
126design. An earlier draft claimed otherwise and was wrong.
127
128**One nuance to absorb:** a *trashed* app passes `HasAppIn` (which filters
129`deleted_at = 0`) and is caught later by `CreateAppIn`'s
130`ON CONFLICT DO NOTHING` (`store.go:1120–1128`) → `claimTaken`. No hole, but
131after the exemption a trashed-name claim burns a bucket listing before hitting
132that refusal. Acceptable; noted so it is not mistaken for a bug.
133
134**Test:** an owner claims a name whose account-qualified prefix has objects and
135succeeds, release history immediately visible through `channels`; a non-owner
136member gets `claimTaken`; **a service principal gets `claimTaken` whatever its
137scope**; a non-member gets the byte-identical 404; a name whose *legacy* prefix
138has objects is refused even for an owner; a name with an `owners` row is
139refused even for an owner.
140
141## 3. Honest verification: the console reports its own build
142
143Both cheap answers lie. `X-Carlos-Version` is stamped by the edge
144(`edge.go:1336–1341`) from the **route row**, so adoption sets it while the old
145process keeps serving. `systemctl is-active` reports "active" for a process
146started from the previous version just as happily — on 2026-08-09 tito said
147"active" while `/proc` said 9668d30, seconds after adopt and restart both
148reported success.
149
150A `carlos deploy -app console` waiting on `X-Carlos-Version` would be **less
151trustworthy than the bash it retires**. So the console reports its own build,
152from the process, compiled in:
153
154```
155GET /healthz → ok <build> # today: "ok"
156```
157
158At least as strong as the `/proc` check and arguably stronger: `/proc` proves
159which *path* is executing, this proves which *code* is, from
160`internal/version.Build` set by ldflags in the same build. Nothing but the
161running process can answer it.
162
163`/healthz` is served by the console's own mux (`server.go:794`), not the edge.
164Existing consumers (`internal/vet`, the edge health probe) check **status
165only**, so appending the build is additive-safe. The build id is not a secret —
166`X-Carlos-Version` already exposes it on every response.
167
168### 3a. Where the probe is declared, and who parses it
169
170`carlos deploy`'s watch gains a second exit criterion: when the app declares a
171**build probe**, poll it instead of the header.
172
173```json
174{"build_probe": {"path": "/healthz"}}
175```
176
177The **path** is the only thing that varies. The contract is fixed: a 2xx whose
178body contains the build id as a **whitespace-delimited token**. Not a regex,
179not a JSON pointer, not a header name — each is a knob whose wrong setting
180produces a watch that passes while proving nothing. Token-matching rather than
181substring is load-bearing: a substring match lets build `abc123` satisfy a
182watch waiting for `abc1234`, and short git shas make that a real collision.
183
184**Storage, and the consumer list** — CLAUDE.md's rule is that when a storage
185shape changes, the consumer list is everything that PARSES the shape:
186
187The probe is **app-level, not per-instance**. The health probe it resembles
188lives in `provision.Record` (`internal/provision/provision.go:33`, wire key
189`"probe"`, at `apps/<sqid>/<app>/instances/<host>.json`) and that is the wrong
190home for this: `handleAPIInstanceUpdate` is a **read-modify-write** of the
191record, so an older console rewriting a newer record would silently drop the
192field. The build probe describes the *app*, not one instance of it, and every
193instance would carry an identical copy.
194
195It therefore lives on the **app row**, served to the CLI on the existing
196per-app API the deploy path already calls, and is not a bucket object at all.
197Parsers: the app row's own query (which names columns explicitly — verified, no
198`SELECT *`), the API handler that serves it, and `carlos deploy`'s watch. Three,
199all in-repo, none of them a wire shape a box parses. That is the point of
200choosing the row over the record.
201
202### 3b. Two ways the watch can still lie, and what closes them
203
204**The ldflags trap.** `carlos deploy` ships a binary the *operator* built.
205`-version` defaults to `gitShortHead()` (`deploy.go:52`) but `version.Build`
206comes from ldflags the operator must remember (default `"dev"`,
207`internal/version/version.go:6`). Build without the flag and the roll
208**succeeds** while the probe reports `ok dev` forever — deploy reports failure
209on a healthy deploy, the exact inverse of the lie §3 exists to kill. Closed
210twice: §5's ship-time refusal when the embedded build ≠ `-version`, and a
211watch timeout that **names what the probe actually said** (a body of `dev` is
212then self-diagnosing).
213
214**The edge cache.** `watchVersion` cache-busts every poll because the edge
215caches proxied 2xx responses up to five minutes (`deploy.go:1013–1022`,
216`edge.go:933`). The console's `/healthz` sends no `Cache-Control` so it is safe
217today, but §3 is a tenant feature and a tenant's probe path may not be. The
218probe leg inherits the buster, and reads the body through the default transport
219so gzip is transparently decoded.
220
221**Test:** the watch reports live only once the probe body carries the shipped
222build as a whole token; `abc123` does not satisfy a watch for `abc1234`; a
223probe stuck on the previous build times out with a diagnosis naming both the
224expected build and the body received; an app with no probe keeps today's header
225behaviour byte for byte; the probe leg sends a cache-buster.
226
227## 4. Restart, rollback, and the recovery hole
228
229`makeRestartRoute` arm 1 (`internal/host/agent.go:2041–2050`) try-restarts a
230route that names its unit and **returns** the failure, so `adoptHost` poisons
231the build, repoints the symlink back and skips `SetVersions`
232(`adopt.go:779–818`). That much is built, and it is what makes a lying header
233impossible *in that case*.
234
235**It is a much narrower guarantee than it looks, and an earlier draft of this
236spec overclaimed it.** Two holes, both verified.
237
238### 4a. The guarantee only covers systemd-visible start failure
239
240`systemctl try-restart` on a `Type=simple` unit succeeds the moment the process
241is exec'd. A console that starts and then panics on its first request, exits
242after a half-applied migration, or listens and 500s, **exits 0**. Then
243`SetVersions` stamps the new build fleet-wide, the §3 probe correctly times
244out — and nothing rolls back. The symlink stays on the broken build, the next
245pass early-returns on `cur == ver` (`adopt.go:637`), and the repair door is the
246thing that is down.
247
248**Requirement:** `console.service` gains a **readiness gate** so that "started
249but not serving" becomes a failed start. `Type=notify` with the console
250signalling readiness *after* migrations have applied and the listener is up is
251the precise form; an `ExecStartPost` readiness poll is the cheap form.
252
253**This is a pinfra change plus a `cmd/console` change**, and it is a
254cross-repo dependency this spec owns. It cannot be assumed: `console.service`
255is defined in pinfra, not in `internal/host/units/`, and its current `Type=` is
256not recorded anywhere in this repo. Step 0 reads it.
257
258The residual after this — a console that starts, serves, passes readiness and
259is *behaviourally* wrong — has no automatic recovery and is break-glass
260(§9). Say so out loud rather than implying the rollback covers it.
261
262### 4b. Rollback repoints the symlink but leaves the console DOWN — and the next pass then lies
263
264This is the more serious of the two and it is **not** in the review that
265prompted this rework; it was found verifying that review.
266
267On restart failure `adoptHost` repoints the symlink to `cur` and returns
268(`adopt.go:781–815`). It does **not** start the unit again. For a `try-restart`
269that failed to bring the service back, systemd leaves it **failed** — so the
270box is left with a correct symlink and no console.
271
272It gets worse on the next pass. `try-restart` is by definition a **no-op on a
273unit that is not running** (the property `makeRestartRoute`'s comment relies on
274so that a deliberately-stopped console stays stopped). So:
275
276```
277pass 1: PointLive(new) → try-restart → FAILS → poison, PointLive(old), no stamp
278 unit: failed. console: DOWN. header: honest (old).
279pass 2: PointLive(new) → try-restart on a NOT-RUNNING unit → no-op, exit 0
280 → SetVersions(new)
281 unit: still failed. console: STILL DOWN. header: claims the new build.
282```
283
284The second pass converts an honest failure into the exact lie the whole
285mechanism exists to prevent, over a console that is not running at all.
286
287**Requirement:** when arm 1's try-restart fails and the symlink has been rolled
288back, adoption must **start** the named unit on the restored version — a
289`systemctl start`, not a try-restart, because the whole point is that the unit
290is down. If that start also fails, the version stays poisoned and unstamped and
291the failure is printed; the box is then genuinely broken and break-glass is
292correct.
293
294This is a **tenant** fix. Any bespoke unit-backed route has this hole today.
295It is the strongest argument in the whole slice for making the platform eat its
296own food: the hole has presumably been there since #110 and nobody found it,
297because the pinfra script's explicit post-pass `systemctl restart` has been
298papering over it on the only unit-stamped route in the fleet.
299
300**Test:** a failed named-unit restart leaves the unit RUNNING on the previous
301version, the symlink on the previous version, and the row unstamped; a second
302pass over the same still-broken build does not stamp; a failed rollback start
303is reported and still does not stamp.
304
305### 4c. One process, several hostnames — the stamped route is not a free choice
306
307The console is one `console.service` behind several hostnames. Adoption is
308**per host** (`reg.List()`, `ORDER BY host`, `registry.go:604`), and each
309`adoptHost` repoints `live/<that host>` (`PointLive(optDir, rt.Host, …)`) and
310*then* fires the restart callback.
311
312So the restart fires inside **one** host's adoption, while the other hosts'
313symlinks have not moved yet. Which means:
314
315> **The route that must carry the `Unit` stamp is the host whose symlink the
316> unit's `ExecStart` resolves — not the canonical hostname, not the first
317> alphabetically.**
318
319Flagship today gets this **wrong**, and the pinfra script has been hiding it:
320
321```
322ExecStart = /opt/carlos/live/platform.carlosframework.com serve
323stamped = console.carloku.com ← sorts BEFORE platform.…
324```
325
326The pass restarts at `console.carloku.com` (2nd of 4 by host order), when
327`live/platform.carlosframework.com` still points at the **old** build; that
328symlink moves last, under an already-running process. Every roll to date has
329worked only because `roll-console.sh` runs an explicit `systemctl restart
330console` **after** the whole pass. Remove that line — which is precisely what
331"the console is an ordinary app" means — and the console keeps serving the old
332build while all its routes advertise the new one.
333
334**Requirement:** step 0 verifies, per box, that the `Unit`-stamped route is the
335host named in `ExecStart`, and restamps if not. Exactly one route is stamped:
336one is sufficient to cycle the process, and stamping several would try-restart
337the same unit several times in one pass.
338
339The unstamped rows' `X-Carlos-Version` then becomes cosmetic, which §3 makes
340harmless. One honesty note: on restart failure the poison map only stops
341**later** hosts in the pass from stamping (`adopt.go:648`), so whether the
342cosmetic headers stay honest depends on alphabetical order relative to the
343stamped host — protection by accident, not by design. The real fix is the
344follow-up below.
345
346**Follow-up to file, explicitly out of scope:** teach the pass to collect
347named-unit restarts and run them **once, after every host's symlink has
348moved**. That makes the stamp/ExecStart coupling and the alphabetical accident
349both irrelevant, and it is the general fix for any bespoke service behind
350several hostnames.
351
352## 5. Ship-time refusals for a control-plane app
353
354Two refusals, both at the ship door, both scoped to apps carrying the §6 flag.
355
356**Architecture.** Read the artifact's ELF header and refuse a machine type that
357does not match the declared `-target`. `-target linux-arm64` being `carlos
358deploy`'s default only protects the careful caller; this closes the path a
359hand-run `carlos ship`, a CI job or a future script takes into the same channel.
360
361**Embedded build.** Refuse a binary whose embedded `internal/version.Build`
362does not equal the `-version` being shipped. This closes §3b's ldflags trap at
363the door instead of at a timeout, and it incidentally closes failure mode #7:
364the root `carlos` binary and `cmd/console` stamp the same `version.Build`, so
365this does not catch a wrong-artifact ship on its own — but combined with the
366probe (only `cmd/console` serves `/healthz`) the pair does.
367
368Scoped to control-plane apps deliberately: a tenant may legitimately ship an
369artifact we would guess wrong about, and refusing their ship on our inference
370is not our call. For the app that *is* the control plane, on boxes we own and
371know are arm64, it is.
372
373**Test:** an amd64 ELF with `-target linux-arm64` is refused naming both the
374declared target and what was found; a binary whose embedded build ≠ `-version`
375is refused naming both; a matching artifact ships; a non-control-plane app is
376unaffected; a non-ELF artifact (a static tarball) is unaffected.
377
378## 6. The control-plane flag gates DESTRUCTION, not promotion — and narrows a service principal's authority
379
380```sql
381ALTER TABLE apps ADD COLUMN control_plane INTEGER NOT NULL DEFAULT 0;
382```
383
384An earlier draft had this flag demand fresh auth to **promote**, "exactly as
385stable already does, at every rung." That was wrong, and wrong in a way that
386defeated the spec's own purpose. `stable`'s API gate is a bare 403 with a
387`console_url` and **no API-side step-up** (`apicli.go:632–638`), and
388`carlos deploy` `log.Fatalf`s on a failed promote *after the ship has already
389landed* (`deploy.go:206–208`). Extending it to every rung would mean
390`carlos deploy -app console` ships the artifact and then dies, every time —
391the exact command this spec exists to create.
392
393**Invert it.** Deploying the console should be easy; destroying it should be
394hard. That is also where the real danger is: `handlePurgeApp` (`ownership.go:293`)
395can delete `apps/bab/console/**` — the release history the fleet is *currently
396running* — behind an owner check, a typed name and fresh auth, none of which
397knows this app is the control plane.
398
399An app carrying the flag:
400
401- **refuses trash, purge, rename and transfer outright.** Not "with fresh
402 auth" — refused, with a message saying the flag must be cleared first. The
403 console's release history is not something to delete behind a confirmation
404 dialog.
405- **refuses promotion by a service principal.** A person with ops membership
406 can roll the console; a CI credential cannot. This closes the hole §10 step
407 ordering would otherwise leave open, without a step-up flow.
408- **refuses an instance-record DELETE outright** (added mid-rollout, 2026-08-28).
409 This is the eighth door and the shortest fuse of the set. `carlos deploy`
410 resolves hosts through `apps/<sqid>/<app>/instances/<host>.json`, so making
411 the console deployable means giving it a record — and a record is an
412 authority transfer: once it exists and the route is `Provisioned`, the
413 provisioning reconciler's removal phase retires the route the moment the
414 record goes. `DELETE /api/cli/apps/{account}/{app}/instances/{host}` is
415 `svcrole.Allow(svcrole.Instance)`, so one call from a person *or* a CI
416 credential holding that role took the control plane off the air. Same class
417 as trash and purge; same refusal. **The console must not become
418 instance-backed until this guard is in place.**
419- **refuses an instance CREATE or PATCH by a service principal.** A person must
420 be able to write the console's record — that is the runbook step the whole
421 §10 sequence turns on — but a CI credential must not acquire the reconciler's
422 authority over the console's route, and PATCH is a repoint: the reconciler
423 converges a route's Channel and Environment *from* this record on every pass.
424 Same person/service split as promote, for the same reason.
425- **refuses a domain DETACH outright, and a domain ATTACH by a service
426 principal.** `DELETE /api/cli/apps/{account}/{app}/domains/{host}`'s
427 first-route branch calls the same `provision.DeleteRecord` the instance
428 delete does, and `POST .../domains`'s first-route branch *writes* a declared
429 record — both registered `svcrole.Allow(svcrole.Publish, svcrole.Instance)`,
430 so a service credential reaches either. Guarding the instance family and
431 leaving these open would be a guard with a published way round it. Detach's
432 browser twin (`detachDomain`) carries the same outright refusal; attach's
433 needs none, because a browser session's principal is always a person.
434 Neither guard is conditional on the record-touching branch: a conditional
435 would have to re-derive "does this claim mint the app's route" ahead of the
436 code that derives it, and the two would drift.
437- gets §5's two ship-time refusals.
438
439**The closure principle — read this before adding a tenth door.** The set above
440is not a list to extend by taste; it is the **`provision.Record` lifecycle**,
441closed. A record is what grants the provisioning reconciler authority over a
442route, so every door that CREATES, MUTATES or REMOVES one for a flagged app
443carries a guard: create (`POST .../instances`, `POST .../domains`) and mutate
444(`PATCH .../instances/{host}`) refuse a **service principal**, because a person
445performing them is a legitimate operator act; remove
446(`DELETE .../instances/{host}`, `DELETE .../domains/{host}`) refuses
447**outright**, because removal is destruction and belongs with trash and purge.
448A future door that touches that lifecycle needs the same guard by the same
449argument, and one that does not, does not.
450
451Two lifecycle sites carry no `control_plane` call and are still covered, which
452is worth writing down so the next audit does not read them as gaps.
453`POST .../instances/{host}/channel` (`channelmove.go`) MUTATES a record, and is
454registered with the **zero `svcrole.Set{}`** — no service principal reaches it
455at all, admin included, which enforces the service half more strictly than a
456refusal would; a person repointing the control plane's channel is the same
457legitimate operator act as promoting it. And `rollbackAttach`
458(`domainattach.go`) deletes a record, but it is not a door: it is the
459compensating delete inside the attach path, reachable only once that path's own
460guard has passed, and it removes only the record the same call just tried to
461write — guarding it would strand half-written attaches instead of protecting
462anything.
463
464Promotion by a **person** is gated by ops membership, which is already the
465operator boundary, and by nothing else. That is what keeps
466`carlos deploy -app console` working as an ordinary command.
467
468**How the flag is set.** Not by a box-local sqlite write — that contradicts the
469no-box-commands corollary. It is set at claim time: `carlos apps create` gains
470`-control-plane`, owner-only, and the console's app settings page can clear it
471(clearing is the dangerous direction, so clearing requires fresh auth; setting
472does not).
473
474**Test:** trash/purge/rename/transfer on a flagged app are refused naming the
475flag; the instance-record DELETE is refused for a person AND for a service, and
476the record survives; a domain detach is refused for a person and a service at
477the API door and for the owner at the browser door, and the claim survives; a
478service principal's promote, retention change, instance create, instance patch
479and domain attach are each refused — the attach in BOTH request shapes, alias
480and first-route, since the guard is not conditional on the branch — and each
481for both service roles the domains routes admit; a person's promote at any rung succeeds with ordinary
482membership, and a person can still CREATE and PATCH the flagged app's instance
483record and ATTACH a domain to it — asserted on the stored record and the stored
484claim, not on a status code, because that half is the runbook step the flag must
485not break; on every service-refused and outright-refused door, clearing the flag
486lets the IDENTICAL call through, which is what proves the flag is the only
487variable; clearing the flag requires fresh
488auth; setting it requires owner; an unflagged app is unaffected in every case;
489and every refusal sits behind the existing 404, so a non-member probing any of
490these doors gets the byte-identical not-found and never learns the flag's
491existence.
492
493## 7. The version stamp and the dirty tree
494
495`roll-console.sh` refuses a dirty tree because the version stamp is the git
496short sha, and a stamp naming a commit the binary does not match is how a false
497"verified" happens. `carlos deploy` already defaults `-version` to
498`gitShortHead()` and inherits the convention — but not the guard.
499
500`carlos deploy` refuses to ship when `-version` was **derived from git** and
501the tree is dirty. An explicit `-version` is the operator saying they mean it,
502and is not second-guessed.
503
504A general fix: any tenant using the default stamp today can ship a binary whose
505sha names a commit it was not built from.
506
507**Test:** dirty tree + derived version refuses before anything ships; dirty
508tree + explicit `-version` ships; clean tree ships.
509
510## 8. The migration has an ordering trap
511
512`store.go`'s `migrate()` runs its `ALTER TABLE apps` block (lines 198–219)
513**before** `migrateAppsKey()` (line 231), whose rebuild copies an **enumerated
514column list** into `apps_new` (271–288) — and the code already carries a
515comment about this hazard at line 280.
516
517Add `control_plane` to that ALTER block and any database that has not yet been
518through the rebuild — a self-hoster jumping versions — has the column
519**silently dropped**, after which every `SELECT control_plane` errors.
520
521**Requirement:** either add the ALTER *after* `migrateAppsKey()`, or add the
522column to `apps_new` and its copy list. The plan picks one and tests the
523jumping-a-version path explicitly.
524
525Otherwise the column is safe: every apps query names its columns (verified —
526no `SELECT *`), so no row parser breaks.
527
528## 9. The pinfra scripts stay
529
530`roll-console.sh` and `verify-console.sh` **stay**, reclassified in their
531header comments as **break-glass**: the path that works when the console is
532down. That is the honest answer to the bootstrap question — the console
533mediates its own replacement on the happy path, §4 covers a build that fails
534readiness, and §4a's residual (starts, serves, behaves wrong) has no automatic
535recovery at all. A console that is already down cannot be the door to its own
536repair.
537
538`ship-app.sh` and `promote-app.sh` lose their console/kass/keymail/
539platform-site callers but are not deleted; `promote-app.sh` still serves
540direct-bucket promotes for apps outside any console's knowledge.
541
542## 10. Order of work
543
544Reordered after review: the flag must exist **before** the claim, and the code
545must reach the boxes **before** the first self-deploy.
546
547**0. Survey both boxes** (read-only, no changes). Per box: `console.service`'s
548 `ExecStart` path and its `Type=`; which route carries the `Unit` stamp;
549 every console route and channel (tito is entirely unsurveyed — it has
550 `console.platform.tito.io` and the still-serving `carlos.tito.io`);
551 `HasOwners("console")`; any legacy `apps/console/` prefix; the bucket
552 production flag on `console`. Flagship's readings are in the appendix;
553 **tito's are not, and step 7 deploys there first.**
554
5551. §2 owner exemption, with the service-principal pin and the `handleNewApp`
556 policy change.
5572. §8 migration + §6 flag, its destructive-verb refusals, its service-principal
558 promote refusal, and `-control-plane` on `apps create`.
5593. §3 `/healthz` carries the build; the app-level build probe; the watch's
560 second exit criterion with cache-busting and a naming timeout.
5614. §4b restart-recovery: start the unit on the restored version after a rolled
562 back adoption. **Tenant fix; lands independently of everything else here.**
5635. §5 ship-time refusals. §7 dirty-tree guard.
5646. **`cmd/console` readiness signalling** (§4a) — the code half of the
565 `Type=notify` change.
5667. **Delivery roll.** One last `roll-console.sh` on both boxes carrying steps
567 1–6, plus a CLI release carrying the new watch. Without this, step 9 runs
568 against a console that predates its own probe and fails.
5698. **pinfra:** `console.service` gains the readiness gate (§4a). Applied to
570 both boxes.
5719. **Stamp correctly** (§4c): on each box, move the `Unit` stamp to the host
572 named in `ExecStart`, exactly one route. Verify with a no-op adopt.
57310. **Claim `ops/console` with the flag set, in one step**, on both
574 deployments. Not two: a row without the flag is a window in which any ops
575 member or `Publish` service principal can roll the production console.
57611. **First `carlos deploy -app console` on tito**, verified against the build
577 probe. Tito first: smaller blast radius, and its console going down does
578 not take carloku with it.
57912. The same on flagship.
58013. Prove `carlos deploy` on `kass`, `keymail`, `platform-site` — each with
581 step 9's precondition first. `kass` and `keymail` are addr-backed bespoke
582 services whose `Unit` stamps are unsurveyed; an unstamped `keymail` proof
583 run is the #110 shape again (symlink swapped, nothing restarted, header
584 lies) and would "prove" a deploy that did not happen.
58514. Update CLAUDE.md's "Deploy reality", which currently sends the reader to
586 the pinfra scripts for all four apps. Reclassify the scripts as break-glass.
58715. File the two follow-ups: the restart-dedupe pass (§4c), and how a **fresh
588 deployment** gets its `ops/console` row (§11).
589
590## 11. Open questions
591
592- **How does a fresh deployment or a self-hoster get the `ops/console` row?**
593 Nothing creates it — not provisioning, not `materializeQuietly`, not
594 `BackfillOpsMemberships`. Steps 10 backfills our two boxes by hand. The
595 general answer belongs to slice 3 or to #135, and is filed at step 15, but it
596 should be named as an unsolved product gap rather than left implicit.
597- **What alarms on a console that is down?** §4a's residual and §4b's
598 rollback both print into the root pass's journal — box-only. The CLI's
599 timeout tells the operator who ran the deploy and nobody else. Out of scope
600 to build here; named so it is not mistaken for covered.
601- **`carlos deploy -app console` run against the console being deployed** is
602 expected to work — the watch tolerates the restart — but its *diagnostics*
603 leg reads the platform log stream through the console API, so it will error
604 precisely when it is most wanted. Confirm at step 11 and degrade the
605 diagnostics gracefully rather than failing the deploy.
606
607## Appendix — flagship readings, 2026-08-23
608
609```
610apps rows in ops (bab): deploy-probe, platform-site (no console)
611bucket prefixes under apps/bab/: apt-carlosframework, carloku, carlosframework,
612 console, deploy-probe, derp, hello, keymail,
613 meep, platform-site
614owners table (grants): derp, meep, platform-site (no console)
615legacy apps/console/: absent
616
617console routes:
618 platform.carlosframework.com | console | canary/console | (no unit)
619 console.bab.oncarlos.com | console | (alias) | (no unit)
620 dashboard.carloku.com | console | canary/console | (no unit)
621 console.carloku.com | console | canary/console | console.service
622
623console.service ExecStart: /opt/carlos/live/platform.carlosframework.com serve
624console.service Type: not read — step 0
625CONSOLE_ORIGIN: https://console.carloku.com
626CONSOLE_DB: /data/console/console.db
627```
628
629The `ExecStart` host and the stamped route disagree. That is §4c.
630