carlos / platform Public

spec: route records — a route's desired state lives in the bucket

Closes the design half of #123 (carlos route is box-local only), #130
(instance-record channel change never reaches the route row) and the
write-converge half of #64.

The obvious reading of #123 — "give carlos route an API mode" — implies
the console reaching into a registry, which only works where the console
is colocated with the box and otherwise needs the unbuilt fleet RPC
(#73). The bucket is already the control plane; the real gap is that
provision.Record describes exactly one route shape and reconcile creates
from a record without ever converging to it.

So: extend the record additively with kind/addr/unit/hibernate/
environment/status_named (zero values reproduce today's mint exactly),
converge Channel and Environment, and adopt an existing route whose shape
matches an appearing record — which makes backfilling the fleet a pure
bucket-side write with no box commands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell c5aa1285d694207cfc21e0d9b2a3ff820bc8e622 parent a86de88
1 file changed, +403 −0
  • docs/superpowers/specs/2026-08-10-route-records-design.md +403 −0
diff --git a/docs/superpowers/specs/2026-08-10-route-records-design.md b/docs/superpowers/specs/2026-08-10-route-records-design.md
new file mode 100644
index 000000000..b28d6b6e9
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-10-route-records-design.md
@@ -0,0 +1,403 @@
+# Route records — a route's desired state lives in the bucket
+
+**Date:** 2026-08-10 · **Status:** draft (design agreed in conversation
+2026-08-10; approach 1 of two chosen — extend the existing record additively
+rather than mint a new primitive) · **Requested by:** Paul
+
+**Closes:** #123 (`carlos route` is box-local only), #130 (instance-record
+channel change never reaches the route row, and the repair is refused),
+and the write-converge half of #64 (`carlos routes`: a registry-mode
+listing verb).
+
+**Extends:** the instance provisioning design (2026-08-04 — the record and
+reconcile pass this generalises), the CLI console API design (2026-08-03 —
+the verb family the new endpoint joins), and `carlos deploy` (2026-08-08 —
+the serial bump that makes convergence seconds rather than an hour).
+
+**Follows on:** #82 (console-driven custom domain attach) builds on this
+primitive rather than beside it — see §9.
+
+## 1. Why
+
+`carlos route` has exactly one source of truth: `-registry`, defaulting to
+`/data/registry.db` (cli.go's `runRoute`). It has no `-console` flag, unlike
+`channels`, `ship`, `promote`, `deploy` and `instances list`. Changing which
+channel a route follows therefore requires a shell on the box, which breaks
+the rule recorded in CLAUDE.md:
+
+> every `carlos` command is designed for a user with no access to the
+> underlying infra and no box commands. The command alone must complete the
+> job.
+
+The obvious reading of #123 is "give `carlos route` an API mode". That
+reading is wrong in a way worth stating, because it decides the whole shape:
+an API mode implies the console reaching into a registry, and the console
+today can only read registries it is colocated with (`view.go`'s
+`openRegistry` opens the DB path directly). Generalising that to more than
+one box means inventing a fleet RPC, which is #73, and which is unbuilt.
+
+The platform already has the answer, and it is the platform's whole premise:
+**the bucket is the control plane**. Desired state goes in the bucket, boxes
+converge. Instance records at `apps/<sqid>/<app>/instances/<host>.json`
+already carry a `Channel` and `provision.Reconcile` already materializes
+them into route rows. The gap is not a missing API. It is that the record
+vocabulary describes exactly one kind of route, and that reconcile creates
+from a record but never converges to it.
+
+### 1.1 Why "there shouldn't be hand-added routes" is the destination, not the state
+
+There are four doors that mint a route: `carlos add` (cli.go), the two
+`domains.Reconcile` alias mints, and `provision.Reconcile`. The record door
+mints one shape, hardcoded:
+
+```go
+Host: rec.Host, Slug: rec.Slug, Kind: "instance",
+Socket: DefaultSocket(rec.Host), DBPath: DefaultDBPath(cfg.DataDir, rec.Host),
+App: rec.App, Account: rec.Account, Pool: rec.Pool, Region: rec.Region,
+Channel: rec.Channel, Hibernate: true, Provisioned: true,
+Seed: seed, Status: "ready",
+```
+
+Single-label subdomain of the app's declared `Domain`, exec-child socket,
+hibernating, `kind=instance`. The `Record` struct has no field for kind,
+addr, unit or hibernate — reconcile supplies all of them by convention.
+
+Compare what `carlos add` accepts: `-kind static`, `-addr`, `-unit`, `-slug`,
+`-db`, `-status-named`, `-hibernate`. Against the deployments named in
+CLAUDE.md, the record shape cannot express **either console** (unit-backed
+`console.service`), **keymail, kass or titogo** (`-addr` whole-app TCP
+tenants), or **the static sites** (carloku.com, platform-site). Those are not
+routes somebody lazily hand-added. They are routes the control plane has no
+words for.
+
+So the work is not "stop hand-adding routes". It is: give the record enough
+vocabulary to describe every route the platform actually runs, then make
+the bucket the normal door and the registry write the documented exception.
+
+## 2. The model
+
+Three changes, each additive, each following an existing pattern:
+
+1. **`provision.Record` grows the missing shape fields** (§3), every one
+ `omitempty` with a zero value that reproduces today's mint exactly.
+2. **`provision.Reconcile` converges and adopts** (§4): it converges
+ `Channel` and `Environment` on records it owns, and it adopts an existing
+ route whose shape already matches an appearing record.
+3. **A member-facing record edit endpoint** (§6), with the version safety
+ rail moved console-side where the pubkey and the store actually are.
+
+`carlos add` becomes a record writer in bucket mode, with its registry write
+surviving behind an explicit break-glass flag (§7).
+
+## 3. The record grows a shape vocabulary (all additive)
+
+`provision.Record` gains six optional fields. The rule every one obeys: the
+zero value is what reconcile does today, so a schema-1 record written before
+this change parses and materializes a byte-identical route.
+
+| Field | JSON | Zero value means | Covers |
+|---|---|---|---|
+| `Kind` | `kind` | `instance` | static sites |
+| `Addr` | `addr` | derive socket via `DefaultSocket` | whole-app TCP tenants |
+| `Unit` | `unit` | no owning unit | `console.service` |
+| `Hibernate` | `hibernate` | `true` | long-running units that must not sleep |
+| `Environment` | `environment` | the app's default bundle | the second member-facing axis of `carlos route` |
+| `StatusNamed` | `status_named` | false | `carlos add -status-named` |
+
+### 3.1 Field notes
+
+**`Hibernate` is `*bool`, not `bool`.** Reconcile mints `Hibernate: true`
+today, and every record already in the flagship and tito buckets has no such
+field. A plain bool would read those as `false` and un-hibernate the fleet on
+the first pass after deploy. `nil` must mean `true`. This is the same
+tri-state honesty `runRoute` already buys with `fs.Visit`, for the same
+reason: absent and false are different facts.
+
+**`Socket` and `DBPath` stay derived, never declared.** `DefaultSocket` and
+`DefaultDBPath` exist precisely so a hand-added route and a provisioned one
+land on identical path shapes, and the `filepath.Join` note in their doc
+comment records what two names for one database cost. Declaring them would
+reintroduce that. The upstream rule is therefore positional:
+
+- `Addr` set → TCP upstream, no socket, no DB path.
+- `Addr` empty, `Kind` instance → derived socket and DB path (today).
+- `Kind` static → neither.
+
+**`Kind` is validated against the registry's own vocabulary**
+(`instance | service | static`), not accepted verbatim. The `-backing unti`
+incident recorded in cli.go — an unvalidated value written straight to a
+column, leaving a route neither unit-backed nor visibly wrong — is the
+failure mode this avoids at the other door.
+
+**`Unit` is validated with `registry.ValidUnitName`** at the console, before
+the record is written, for the reason both existing doors already state: a
+route created with a typo'd unit is a route whose adoption silently restarts
+nothing.
+
+**`Unit` and `Kind: instance` + unit-backing remain mutually exclusive**, the
+same refusal `runAdd` and `unitRail` already carry, at this third door.
+
+### 3.2 Host validation
+
+`provision.ValidHost` requires exactly one DNS label plus the app manifest's
+`Domain`, and its doc comment states why: the host becomes a bucket key
+segment and, on the box, the `<host>.sock` and `<host>.db` filesystem path
+segments. That is path-traversal defence, and it is not loosened casually.
+
+It also cannot express an apex (`carloku.com`) or a console's own hostname,
+which is most of what needs backfilling.
+
+The resolution follows the derivation rule in §3.1: **a record validates with
+`ValidHost` when it derives filesystem paths, and with the looser
+`ValidHostShape` when it does not.** Concretely:
+
+- `Kind: instance` with no `Addr` — derives `.sock` and `.db` paths →
+ `ValidHost`, suffix-pinned, unchanged.
+- `Kind: static`, or any record with `Addr` set — derives no filesystem path
+ at all → `ValidHostShape`.
+
+The traversal safety comes from not building a path out of the host, rather
+than from the suffix. The suffix pin remains exactly where the danger is.
+
+The sibling-apex check in `handleAPIInstanceCreate` (one app quietly serving
+another's public URL) is unaffected: it is scoped to
+`m.Domain == acct.Sqid+"."+s.cfg.AppsDomain`, which a custom-domain or apex
+record never enters.
+
+## 4. Reconcile converges and adopts
+
+Two changes to `provision.Reconcile`.
+
+### 4.1 Converge (closes #130)
+
+The `existing.Provisioned && existing.App == rec.App && existing.Account ==
+rec.Account` branch converges `Seed` and nothing else today. It should also
+converge `Channel` and `Environment`, and the §3 shape fields.
+
+This is #130 closed structurally rather than patched. That incident — a
+record created on `stable`, deleted and recreated on `edge`, with the route
+row left on `stable` and every wake failing `no adoptable head` while
+`carlos channels` cheerfully showed `edge` promoted — becomes
+unrepresentable. Member-visible state and box state cannot disagree about a
+column the bucket owns.
+
+`Seed`'s existing `seedIsKnown` guard is the pattern to copy: a transient
+manifest-read failure must never stamp a `""` fallback over a working value.
+Channel has no manifest dependency, so it converges directly from the record.
+
+### 4.2 Adopt on match
+
+The `default:` branch today logs "exists but was not minted by this pass —
+leaving it alone" and moves on. It becomes:
+
+- If the existing route's shape **matches** the record's declared shape,
+ stamp `Provisioned` and take ownership. Log the adoption.
+- If it **differs**, leave it alone exactly as today, and log loudly enough
+ that the mismatch is diagnosable without a box session.
+
+This is what makes the migration cheap, and it is the design's main payoff.
+Backfilling the fleet becomes a pure bucket-side write per route — no
+`carlos add`, no SSM session, no on-box verb. Write the record that describes
+what is already there; the next serial bump adopts it. Get the shape wrong
+and nothing happens, with the log saying so.
+
+"Matches" is: `App`, `Account`, `Kind`, `Addr`, `Unit`, `Slug`, `Pool`,
+`Region` all equal, and the derived socket/DB paths equal what the record
+would derive. `Channel`, `Environment` and `Hibernate` are deliberately
+**excluded** from the match test — those are the columns the record is taking
+ownership of, and requiring them to agree first would make adoption
+impossible in exactly the case it is for.
+
+### 4.3 The risk this creates, stated plainly
+
+Adoption grants reconcile's removal phase authority over the route, because
+that phase keys on `Provisioned`. A sloppy shape-match check could retire a
+live route.
+
+The existing guards are real and stay: `readFailed` disables removal
+pass-wide, `protect[]` covers a record that exists but could not be read
+cleanly, and scope is region/pool-bound. On top of them:
+
+- A `-dry-run` mode that prints the record it would write for every route on
+ a box, and for each, whether it would adopt, mint or leave alone.
+- Backfill applied one route at a time, verified through the serial, with
+ the two consoles **last** — a console that retires its own route cannot be
+ used to put it back.
+
+## 5. Reconcile owns `Channel` and `Environment`, so the CLI must stop writing them
+
+Once a route is `Provisioned`, `carlos route -channel` or `-environment` on
+the box is a write that gets stomped within seconds of the next serial bump.
+Silently. That is the same class of bug as #130, arriving from the other
+side.
+
+Both are refused, with a message naming the record as the door.
+
+The precedent to copy is exact: `platformCanaryOwnedRow` already refuses
+`-environment` on platform and canary routes, and the `Environment` doc
+comment in registry.go states the reason — "the reconciler owns those rows
+and re-Upserts them whenever its `followed` check goes false … a column the
+reconciler rebuilds is not one this verb should be handing an operator."
+One more owner, same rail, same voice.
+
+`-grant`, `-revoke`, `-backing`, `-unit` and `-tunnel` are untouched by this:
+they are box-scoped columns the record does not own.
+
+## 6. The API
+
+```
+PUT /api/cli/apps/{account}/{app}/instances/{host}
+```
+
+Joining `registerCLIRoutes` beside the existing `POST`/`GET`/`DELETE` on the
+same path family, with that family's gate ordering and byte-identical 404
+refusal.
+
+**Body** carries the member-mutable subset only:
+
+```json
+{ "channel": "edge", "environment": "-", "allow_version_change": false }
+```
+
+`environment: "-"` is the clear sentinel, the same one `carlos route` and
+`carlos env` already use, for the same reason (a bare hyphen fits
+`[a-z0-9-]{1,32}` and would otherwise name a real bundle).
+
+**Not member-mutable:** `kind`, `addr`, `unit`, `hibernate`, `slug`, `pool`,
+`region`. The record can *express* them so a route can be declared
+declaratively; editing them is operator domain. A body naming one is refused
+rather than ignored — a flag that is silently dropped is how an operator
+comes to believe a change landed.
+
+**Concurrency:** read-modify-write CAS'd on the record's etag via a new
+`provision.PutRecordIfMatch`, mirroring the `PutManifestIfMatch` that already
+exists. A lost race is a 409, never a silent overwrite — the same discipline
+`CreateRecord`'s create-only write carries.
+
+**Serial:** the handler calls `s.bumpSerial(ctx)` after the write, exactly as
+`handleAPIInstanceCreate` does, because this is a box-consumed write. The
+repoint is live in seconds.
+
+**Audit:** `s.audit(acct, app, principal, "instance-repoint", …)`.
+
+### 6.1 The version rail moves console-side
+
+`routeVersionCheck` (cli.go) resolves whether a repoint changes what gets
+served. It needs `CARLOS_RELEASE_PUBKEY` and bucket access — an API-mode
+caller on a laptop has neither, by design. The console has both
+(`s.cfg.Releases`), so the check runs in the handler, refusing 409 with the
+same note text the CLI prints today. `allow_version_change` is the request
+field standing in for the flag.
+
+The three outcomes are unchanged: resolved-and-agreeing proceeds; resolved-
+and-disagreeing is the override's intended door; unprovable is never
+overridable.
+
+### 6.2 One targeted fix inside the rail (closes #130's second ask)
+
+Today the branch where the route has no recorded `Version` **and** its
+current channel does not resolve returns `overridable: false` — refused
+regardless of the flag. #130 hit exactly this trying to repair the route it
+describes, and had to fall back to `carlos remove` and a re-mint.
+
+That branch is only reached **after the target channel has already resolved
+and verified**. The route has never served anything, and the stale channel is
+unresolvable because it was always wrong. There is no comparison to protect,
+only a stuck route. It becomes overridable.
+
+The genuinely unprovable cases stay unoverridable, unchanged: no pubkey, an
+unresolvable target, a bad signature. The distinction is whether the
+*target* is proven, not whether the comparison is complete.
+
+## 7. `carlos add` becomes a record writer
+
+In bucket mode (`CARLOS_DEPLOYMENT_BUCKET` / `CARLOS_DEPLOYMENT_DIR` set —
+`resolveMode`'s direct mode, which is what pinfra scripts and box-side
+bootstrap already run with), `carlos add` writes a record instead of a
+registry row. Its existing flags map onto §3's fields one for one, which is
+the point of having chosen that vocabulary.
+
+The registry write survives behind an explicit `-local` flag, documented as
+break-glass: standing up a box before it has a control plane, or repairing
+one that cannot reach the bucket.
+
+That is the operational form of "no hand-added routes" — not removing the
+door, but making the normal path go through the bucket and making the
+exception announce itself. The alternative, deleting the local path outright,
+fails the first time a box is partitioned and someone needs a route back.
+
+## 8. Testing
+
+**Wire shape (the rule this repo has paid for three times).** A fixture of a
+schema-1 record carrying none of the §3 fields, asserted to mint a route
+byte-identical to today's — field by field, not `reflect.DeepEqual` against a
+freshly built expectation, so the assertion still means something if the mint
+changes. This is the test that protects the live records in the flagship and
+tito buckets.
+
+**Reconcile.**
+- Converge: a record whose `Channel` changed moves the route's column;
+ `Environment` likewise.
+- Adopt on match: an existing hand-added route plus an appearing record whose
+ shape matches → `Provisioned` stamped, columns converged.
+- Refuse on mismatch: shape differs → route untouched, mismatch logged.
+- Removal authority unchanged: a hand-added route with no record is never
+ removed; a `readFailed` pass removes nothing.
+- Adoption does not depend on `Channel`/`Environment`/`Hibernate` agreeing
+ (§4.2), pinned directly — it is the property most likely to be "tidied"
+ into a full-shape comparison by a later reader.
+
+**Console.**
+- The member gate and byte-identical 404 for a non-member (the ordering rule
+ in CLAUDE.md: session → CSRF → owner → `requireFreshAuth` last).
+- CAS conflict → 409.
+- A body naming a non-mutable field → refused, not ignored.
+- The version rail's three outcomes, including §6.2's newly-overridable case.
+- Serial bumped after a successful write.
+
+**CLI.**
+- API mode: `-channel` and `-environment` reach the endpoint.
+- API mode: `-grant`/`-revoke`/`-backing`/`-unit`/`-tunnel` refused as
+ box-scoped, naming why.
+- Direct mode on a `Provisioned` route: `-channel` refused, naming the record
+ (§5).
+- Direct mode on a hand-added route: unchanged.
+- Every test shelling out to the binary builds its env through
+ `carlosTestEnv` and sets `cmd.Dir` to a scratch dir — non-negotiable, and
+ the reason is recorded in CLAUDE.md.
+
+**Channel names.** Production runs on `canary/*`; `laddercheck_test.go` pins
+them. Nothing here narrows `release.ValidChannel`, and a test asserts a
+`canary/*` repoint is accepted end to end.
+
+## 9. Out of scope
+
+- **#82, custom domain attach.** This design makes it buildable — a record
+ may already name a host outside the app's declared domain when it derives
+ no filesystem path (§3.2). What #82 still owes is **ownership
+ verification**: proving the member controls the domain before a record
+ naming it is honoured. That is a genuinely separate concern and does not
+ belong in this primitive.
+- **#73, the fleet API.** Nothing here needs box RPC; that is the point.
+- **`-grant`/`-revoke`/`-backing`/`-tunnel` as record fields.** Capabilities
+ are operator grants scoped to one box, and registry.go's `Caps` comment
+ records why the column lives where no tenant can write. They stay there.
+- **Migrating the alias routes** minted by `domains.Reconcile`. Those already
+ have a declarative owner; they are simply a different one.
+
+## 10. Rollout
+
+1. Record fields + validation, with the §8 wire-shape test. No behaviour
+ change: nothing writes the new fields yet.
+2. Reconcile converge + adopt, with `-dry-run`, **plus §5's refusal in the
+ same roll**. The two must land together: a `Provisioned` route whose
+ channel the CLI will still happily write is precisely the window where the
+ silent stomp is possible. Still no behaviour change in production at this
+ point, because no record carries a new field and no new record exists.
+3. Console endpoint + rail move + §6.2's fix. `carlos route` API mode.
+4. `carlos add` record mode, `-local` break-glass.
+5. Backfill, one route at a time, consoles last (§4.3).
+
+Steps 1–2 are one coherent PR and step 3 is another. Step 5 is an operational
+task, not a code change, and wants its own plan with per-route verification
+through `X-Carlos-Version` on the URL — never a cached answer.