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