carlos / platform Public
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 + SSMThat 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.
// 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.
ownerClaimis FALSE for every service principal. A service has no role at all, and an account-scopedPrincipalServicewithApp == ""legitimately reachesclaimApp(apicli.go:346–350blocks 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,claimAppalso checksrelease.LegacyAppPrefix(name)—apps/<app>/, unqualified. Those objects predate account scoping and are owed anaccounts migrate -from legacy. The exemption applies only to the account-qualified prefix. HasOwners(name)still blocks. A CLI-granted app inopsstays 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.
{"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
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.Fatalfs 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 deployresolves hosts throughapps/<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 isProvisioned, the provisioning reconciler's removal phase retires the route the moment the record goes.DELETE /api/cli/apps/{account}/{app}/instances/{host}issvcrole.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 sameprovision.DeleteRecordthe instance delete does, andPOST .../domains's first-route branch writes a declared record — both registeredsvcrole.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.
- §2 owner exemption, with the service-principal pin and the
handleNewApppolicy change. - §8 migration + §6 flag, its destructive-verb refusals, its service-principal promote refusal, and
-control-planeonapps create. - §3
/healthzcarries the build; the app-level build probe; the watch's second exit criterion with cache-busting and a naming timeout. - §4b restart-recovery: start the unit on the restored version after a rolled back adoption. Tenant fix; lands independently of everything else here.
- §5 ship-time refusals. §7 dirty-tree guard.
cmd/consolereadiness signalling (§4a) — the code half of theType=notifychange.- Delivery roll. One last
roll-console.shon 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. - pinfra:
console.servicegains the readiness gate (§4a). Applied to both boxes. - Stamp correctly (§4c): on each box, move the
Unitstamp to the host named inExecStart, exactly one route. Verify with a no-op adopt. - Claim
ops/consolewith the flag set, in one step, on both deployments. Not two: a row without the flag is a window in which any ops member orPublishservice principal can roll the production console. - First
carlos deploy -app consoleon tito, verified against the build probe. Tito first: smaller blast radius, and its console going down does not take carloku with it. - The same on flagship.
- Prove
carlos deployonkass,keymail,platform-site— each with step 9's precondition first.kassandkeymailare addr-backed bespoke services whoseUnitstamps are unsurveyed; an unstampedkeymailproof run is the #110 shape again (symlink swapped, nothing restarted, header lies) and would "prove" a deploy that did not happen. - 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.
- File the two follow-ups: the restart-dedupe pass (§4c), and how a fresh deployment gets its
ops/consolerow (§11).
11. Open questions
- How does a fresh deployment or a self-hoster get the
ops/consolerow? Nothing creates it — not provisioning, notmaterializeQuietly, notBackfillOpsMemberships. 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 consolerun 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.dbThe ExecStart host and the stamped route disagree. That is §4c.