spec: a static site gets a URL from the CLI, and a route's channel can change
Two halves of one complaint: a member cannot get a static site onto a URL
without owning a domain, and cannot fix a route that landed on the wrong
channel without bucket credentials.
Static platform host (#186, static half). Every mechanism already exists —
*.oncarlos.com synthesises at any depth (measured: a.b.c.oncarlos.com
answers the edge), the edge derives per-account wildcard parents itself,
and handleAPIInstanceCreate already reads a kind field whose own comment
anticipates "kind":"static". The missing piece is one call. deploy mints
<app>.<sqid>.<apps-domain> through the instances door when a static deploy
has no -host and no route, before the ship, and prints one line.
deniedDomains is untouched: the deny on the apps domain stays absolute,
and its comment ("hosts under the apps domain are what carlos instances
exists for") becomes true rather than aspirational.
The rail that must not bend: the mint sends its channel explicitly. An
omitted channel defaults a record to stable while a fresh app's pipeline
entry is edge — a route that serves nothing, with no door to fix it.
Which is the second spec. A route's channel is record-owned and no door
can change it; repairing hello.bdn took a raw S3 write. New door, gated on
BOTH ends of the move at their strictest — guarding only the destination
misses the bypass entirely: move a guarded route onto an unguarded channel
and its URL serves unreviewed code with nobody's approval.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 files changed,
+380
−0
docs/superpowers/specs/2026-08-23-record-channel-change-design.md+179 −0docs/superpowers/specs/2026-08-23-static-platform-host-design.md+201 −0
diff --git a/docs/superpowers/specs/2026-08-23-record-channel-change-design.md b/docs/superpowers/specs/2026-08-23-record-channel-change-design.md| new file mode 100644 |
| index 000000000..3876b70fc |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-23-record-channel-change-design.md |
| @@ -0,0 +1,179 @@ |
| +# Changing which channel a route follows — design |
| + |
| +The second half of the `hello.bdn` incident (2026-08-22). The repair |
| +needed a raw S3 write to an unsigned bucket object and a hand-bumped |
| +serial, because no door exists. |
| + |
| +## 1. The defect |
| + |
| +A route's channel belongs to its instance record. That ruling is right and |
| +is not in question: the provisioning reconciler converges `Channel` from |
| +`apps/<sqid>/<app>/instances/<host>.json` on every pass, which is what |
| +makes a route's channel durable across registry rebuilds and second boxes. |
| + |
| +The consequence is that **nothing can change it**: |
| + |
| +- `carlos route -channel` on the box refuses a provisioned row and says |
| + why (`cli.go:772`) — correctly, since the reconciler reverts it "within |
| + seconds of the next serial bump". |
| +- `PATCH /a/<sqid>/apps/<app>/instances/{host}` carries `addrs` and |
| + `probe`, and its doc states the rest of the record is fixed at create: |
| + identity, policy, region. |
| +- The console's instances page is read-only on this axis. |
| + |
| +So a member who lands a route on the wrong channel — which the platform |
| +makes easy, since an omitted channel defaults a record to `stable` |
| +(`apiinstances.go:575`) — has a URL that serves nothing and no way back. |
| +Tony's `hello` was unreachable for the three days it took to notice, and |
| +the fix was performed with credentials no tenant has and no tenant should. |
| + |
| +This is the CLAUDE.md rule verbatim: a day-to-day task with no API/CLI |
| +command is a product gap. |
| + |
| +## 2. The door |
| + |
| +`POST /a/<sqid>/apps/<app>/instances/{host}/channel`, body |
| +`{"channel": "<name>"}`. |
| + |
| +Its own door rather than a new field on `PATCH /instances/{host}`. The |
| +PATCH door is deliberately ungated — upstreams and probes are operational |
| +fields any member may move — and channel is the one record column with |
| +release-authority consequences. Widening PATCH would put a gated field |
| +behind an ungated handler and make the authority story of that door |
| +conditional on which key the body happened to carry. One door, one |
| +authority story. |
| + |
| +The write is `provision.UpdateRecord` under the etag the record was read |
| +with, then `bumpSerial` and an audit line, in that order — the ordering |
| +every console-mediated write in this codebase already uses. Boxes converge |
| +the route's `Channel` column on the next pass, seconds later. |
| + |
| +## 3. The gate |
| + |
| +A channel move is not a promotion, but it can achieve one. This is the |
| +hole worth naming precisely, because guarding the wrong end leaves it |
| +open: |
| + |
| +> An app has `edge` (no rules) and `production` (`promote_approvals: 2`). |
| +> Promoting `v5` onto `production` needs two approvals. Instead, move the |
| +> production route onto `edge`. Nobody approved anything, and the |
| +> production URL now serves `v5`. |
| + |
| +Guarding only the **destination** misses this entirely — the destination |
| +is the unguarded channel. Guarding only the **source** lets a route be |
| +walked onto a channel whose policy the mover never met. So: |
| + |
| +**A channel move must clear the rules of BOTH ends, each at its |
| +strictest.** |
| + |
| +- passkey required if either the from-channel or the to-channel requires it; |
| +- approvals = the maximum, across both channels, of that channel's |
| + `promote_approvals` and `change_approvals`. |
| + |
| +`promote_approvals` is included deliberately even though this is not a |
| +promotion: it is the number that expresses "this channel's URL only serves |
| +what N people agreed to", and a move is exactly a way to change what that |
| +URL serves. |
| + |
| +For a **legacy app** (no `pipeline.json`) the rule collapses to the |
| +console's existing one: either end on the `stable` rung needs a passkey — |
| +the byte-identical 403 with `console_url` that the promote door already |
| +returns — and `edge`/`beta` are free. |
| + |
| +`canary/*` stays outside the pipeline exactly as the pipelines spec |
| +reserves it: always allowed, no rules, no bake. |
| + |
| +Mechanically this reuses what exists: `channelRules(pipe, channel)` for |
| +each end, `proposeOverAPI` with the computed quorum when approvals are |
| +required, and the passkey 403 naming the console when a passkey rule |
| +stands alone with nothing to propose. The CLI proposes and never confirms, |
| +per §4 of the pipelines spec. |
| + |
| +### 3.1 The target must be a channel the app has |
| + |
| +Refused otherwise, naming the app's channels — the same shape as the |
| +promote door's refusal for a channel the pipeline does not list. A |
| +pipelined app is a reason to govern its own channels, never a way to reach |
| +one it left out. |
| + |
| +### 3.2 Bake windows need nothing here |
| + |
| +A route moved onto a channel whose head is inside its bake window simply |
| +does not adopt until the window expires: `HoldFor` is enforced box-side, |
| +against the box's own clock, and the ratchet is unchanged by anything this |
| +door writes. No console-side bake check is added, and none should be — |
| +duplicating that decision in a second place is how the two drift. |
| + |
| +## 4. The proposal payload |
| + |
| +`{host, from, to, etag}`. Execution re-reads the record and refuses with |
| +the shared `staleProposal` error if the record moved underneath the |
| +proposal — the pipelines-v2 rule that a proposal pins its exact payload, |
| +and that a world which moved invalidates it. The etag is the pin; `from` |
| +is what the audit line and the console summary read back to a confirmer, |
| +so what they approve is stated in the terms they were shown. |
| + |
| +`kindRouteChannel = "route-channel"` joins the three existing kinds in |
| +`executePending`'s switch and in `pendingSummary`. |
| + |
| +## 5. CLI |
| + |
| +``` |
| +carlos instances set-channel -app <a> -host <h> -channel <c> |
| +``` |
| + |
| +Mirrors `set-upstreams`, which is the existing verb for "change one field |
| +of one record". Output names both ends: |
| + |
| +``` |
| +moved mysite.bab.oncarlos.com from stable to edge |
| +``` |
| + |
| +For a `kind:static` record it adds the note deploy already prints — a |
| +static route re-checks its channel about every 60s, so the URL can take |
| +that long to flip. A gated move prints the console URL and exits cleanly, |
| +the shape `carlos deploy` uses for a proposed promote. |
| + |
| +`carlos route`'s existing `vRecordOwned` refusal gains one line naming |
| +this command. That refusal already explains why the box cannot do it; the |
| +missing half was where the member should go instead. |
| + |
| +## 6. Not in scope |
| + |
| +- **`Environment`.** The same record-owned shape and the same missing |
| + door, but a different defect with its own issue (#196: binding a route |
| + to an environment does not re-materialize its delivered config). Adding |
| + the axis here without that fix would ship a door whose effect is |
| + silently partial. |
| +- **Platform+Canary rows.** The domains reconciler derives and re-asserts |
| + those channels; `carlos route` already refuses them and this door |
| + refuses them for the same reason, with the same message. |
| + |
| +## 7. Testing |
| + |
| +Console: |
| + |
| +- A move on a legacy app between `edge` and `beta` lands; either end on |
| + `stable` is the passkey 403 with `console_url`. |
| +- A pipelined app with `promote_approvals: 2` on `production`: moving the |
| + production route to `edge` proposes with quorum 2 — the bypass in §3, |
| + asserted as a test, from the unguarded destination. |
| +- The union takes the maximum: `change_approvals: 3` on the source with |
| + `promote_approvals: 1` on the target proposes with quorum 3. |
| +- A target the pipeline does not name is refused, and the message lists |
| + the app's channels. |
| +- A stale etag at execution returns `staleProposal`. |
| +- The write bumps the serial and writes exactly one audit line. |
| + |
| +CLI: `set-channel` sends host and channel; a 202 prints the console URL |
| +and exits 0; a static record prints the recheck note. Tests that shell out |
| +build their environment through `carlosTestEnv` with a scratch `cmd.Dir`. |
| + |
| +Live: move a throwaway app's route between channels on flagship and |
| +confirm with `carlos routes` and the `X-Carlos-Version` header that the |
| +box converged. |
| + |
| +## 8. What this closes |
| + |
| +`hello.bdn` is repairable by its owner from a terminal, with no bucket |
| +credentials — which was the whole complaint. |
diff --git a/docs/superpowers/specs/2026-08-23-static-platform-host-design.md b/docs/superpowers/specs/2026-08-23-static-platform-host-design.md| new file mode 100644 |
| index 000000000..be62014d7 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-23-static-platform-host-design.md |
| @@ -0,0 +1,201 @@ |
| +# A static site gets a URL from the CLI alone — design |
| + |
| +Issue #186 (first-route provisioning), narrowed to its static half. |
| + |
| +`carlos deploy` in a fresh directory logs you in, resolves your account, |
| +creates the app, takes the artifact and saves the project defaults — and |
| +then stops. A static app has no instance records and no `-host`, so |
| +`staticDeployHosts` refuses: |
| + |
| +> this app has no instance records — a static site is served by the edge |
| +> straight from its channel pointer … Name the hostname the site answers on |
| + |
| +The member's answer to "what hostname?" is supposed to be the platform's |
| +own: `<app>.<sqid>.<apps-domain>`. Every mechanism that serves that name |
| +already exists. Nothing mints it. |
| + |
| +## 1. What is already true |
| + |
| +Measured on main at `a347034`, not assumed: |
| + |
| +- **DNS costs nothing.** `*.oncarlos.com` synthesises at any depth — no |
| + intervening node exists in the zone — so `a.b.c.oncarlos.com` already |
| + answers `99.81.104.219`. A brand-new `<app>.<sqid>.oncarlos.com` resolves |
| + to the edge the moment it is typed. There is no per-account record to |
| + write and no DNS seam to build. |
| +- **TLS costs nothing.** `internal/edge/wildcard.go` derives per-account |
| + wildcard parents `*.<sqid>.<apps-domain>` from the live route table and |
| + obtains them over DNS-01. A minted route makes its own parent appear. |
| +- **The door already accepts the shape.** `handleAPIInstanceCreate` |
| + (`internal/console/apiinstances.go:495`) reads a `kind` field, validates |
| + the host as one DNS label plus the app's own manifest domain, and refuses |
| + a sibling app's apex with a 409. Its own comment anticipates this exact |
| + request: *"a `"kind":"static"` body cannot reach for a hostname outside |
| + the app's own domain"*. `provision.ValidateShape` has carried the static |
| + rails since #137. |
| +- **The channel is no longer a trap.** Release pipelines v2 gives a fresh |
| + app a one-channel `edge` pipeline at create |
| + (`release.DefaultPipelineChannels`), and `deployTargets` |
| + (`deploy.go:534`) already resolves that entry channel when an app has no |
| + channels yet. `edge` holds for `0s`, so the first-stable-promote bake |
| + window that made `hello.bdn` unreachable for three days cannot recur on |
| + this path. |
| + |
| +So the gap is one missing call, not a missing subsystem. |
| + |
| +## 2. What the member sees |
| + |
| +``` |
| +$ carlos deploy # zero-arg, fresh directory, ./dist |
| +using console.carloku.com as paul@keymail.dev |
| +created app mysite |
| +minted mysite.bab.oncarlos.com (static, channel edge) |
| +shipped mysite a1b2c3d |
| +promoted mysite a1b2c3d -> edge |
| +deploying https://mysite.bab.oncarlos.com |
| +a static route re-checks its channel about every 60s, so the URL can take that long to flip |
| +mysite.bab.oncarlos.com serving a1b2c3d |
| +``` |
| + |
| +`carlos deploy -kind static ./dist` against a named app with no route |
| +behaves identically. The mint is silent — one line in the same voice as |
| +today's `attached <host>` from `attachUnclaimedHost` — because a deploy |
| +that already auto-attaches a hostname you named should not interrogate you |
| +about the one it can derive. |
| + |
| +## 3. Mechanism |
| + |
| +Two existing doors, called in order, before the ship: |
| + |
| +1. **`POST /a/<sqid>/apps/<app>/instances/enable`** — only when the app has |
| + no manifest. Idempotent upsert; in signed-in mode the console pins |
| + `Domain = <sqid>.<apps-domain>` and refuses the operator fields |
| + (`-domain`, `-pool`, `-regions`). Writing this manifest is the app's |
| + opt-in to instance provisioning (§3.1 of the instance-provisioning |
| + spec), and "this app may hold names in the platform namespace" is |
| + exactly what we mean by it. |
| +2. **`POST /a/<sqid>/apps/<app>/instances`** with |
| + |
| + ```json |
| + {"host": "<app>.<sqid>.<apps-domain>", "kind": "static", "channel": "<resolved>"} |
| + ``` |
| + |
| +Everything downstream is untouched: the provisioning reconciler mints a |
| +`kind:static` route from the record, the edge serves that channel's tar, |
| +and the wildcard parent follows. |
| + |
| +### 3.1 The channel is explicit, always |
| + |
| +This is the one rail that must not bend. With no channel in the body and |
| +no channel on the manifest, the door defaults the record to **`stable`** |
| +(`apiinstances.go:575`) — while a fresh app's pipeline entry is `edge`. |
| +The record would then follow a channel nothing ever promotes to, and |
| +**there is no door to change a record's channel afterwards**: `carlos |
| +route -channel` on the box is reverted by the provisioning reconciler |
| +within seconds of the next serial bump, and `PATCH /instances/{host}` |
| +covers only `addrs` and `probe`. Repairing `hello.bdn` took a raw bucket |
| +write. |
| + |
| +So the mint sends the channel `deployTargets` resolved, and a test asserts |
| +the body carries it. (The missing door is real and is fixed in its own |
| +spec, `2026-08-23-record-channel-change-design.md`; this rail stands even |
| +once that lands, because a record born on the wrong channel is still a |
| +route that serves nothing until someone notices.) |
| + |
| +### 3.2 Where it hooks into deploy |
| + |
| +`staticDeployHosts`'s refusal becomes a **typed** `errNoStaticHost`, |
| +following the `errAmbiguousChannel` precedent. The reason is recorded in |
| +that precedent's own comment: matching on error text once turned the |
| +no-instances refusal — which merely *mentions* `-channel` — into a ship and |
| +promote after an unrelated question. Deploy acts on the type or not at all. |
| + |
| +`runDeploy` catches it, resolves the channel, mints, and continues with |
| +`hosts = [host]` and `channel = record.Channel`. The mint runs **before** |
| +the ship, matching `attachUnclaimedHost`'s ordering: a route whose channel |
| +pointer does not exist yet is inert, not broken, and the alternative — ship |
| +first, mint after — leaves a published version with nothing serving it if |
| +the mint fails. |
| + |
| +Idempotency is free. After the first deploy the static record is in the |
| +instance list, so `staticDeployHosts` resolves the host from it and the |
| +mint is never reached again. |
| + |
| +## 4. Refusals |
| + |
| +Surfaced verbatim, never papered over: the 409 sibling apex ("`x` is app |
| +`y`'s platform apex — create it as an instance of `y` instead"), the |
| +instance-budget refusal, and every shape refusal `ValidateShape` owns. An |
| +app that already has a non-static record is not on this path at all |
| +(`staticOnlyRecords`), and an explicit `-host` keeps today's |
| +`attachUnclaimedHost` behaviour unchanged. |
| + |
| +One case needs a deliberate fallback: **a deployment with no |
| +`PLATFORM_APPS_DOMAIN`** — a self-hosted box, tito's shape. The enable |
| +door answers 503 naming console configuration. Deploy must not surface |
| +that to somebody who only wanted a URL: it falls back to today's "name the |
| +hostname with `-host`" refusal, which is the honest answer on a deployment |
| +that has no platform namespace to mint from. |
| + |
| +## 5. CLI surface |
| + |
| +- `carlos instances create -kind static` exposes the kind the door already |
| + accepts (`instanceCreate` in `apimode.go:2026` gains a `kind` |
| + parameter; empty keeps today's instance default). |
| +- `carlos deploy -host` changes meaning in its help text only: from |
| + "required for an app with no instances" to "serve on this hostname |
| + instead of the app's platform host". |
| + |
| +No new verb. "Instances" remains odd vocabulary for a site with no |
| +process; the member never types it, because deploy does. Recorded as a |
| +naming debt, not fixed here. |
| + |
| +## 6. What this deliberately does not touch |
| + |
| +- **`deniedDomains` stays absolute.** The apps domain and everything under |
| + it remain unclaimable through `carlos domains attach`. That door is for |
| + hostnames the caller's own DNS controls; the platform namespace belongs |
| + to the instances door, exactly as its comment says. |
| +- **The domains reconcile pass is unchanged.** Its apex mint is skipped by |
| + the existing `byHost` guard, and `routeFor` |
| + (`internal/provision/reconcile.go:71`) sets no `Platform` column — so the |
| + pass never gains removal authority over a record-minted route. Both |
| + checked against the code, not reasoned about. |
| +- **Binary apps keep their dead-end.** #186's other half needs region, |
| + pool and upstream-shape decisions a static route does not have. Left |
| + open, and the issue says so. |
| + |
| +## 7. Testing |
| + |
| +Unit, in `deploy_test.go`: |
| + |
| +- `deployTargets` returns `errNoStaticHost` (typed) for a static kind with |
| + no `-host` and no records; still returns it when records exist but are |
| + all non-static. |
| +- The mint body carries both `kind: "static"` and the resolved channel. |
| +- No mint when a static record already exists, and none when `-host` was |
| + given. |
| +- The apps-domain-less deployment falls back to the `-host` refusal. |
| + |
| +Console, in `apiinstances_test.go`: |
| + |
| +- `kind: "static"` at the app's own apex is created, and the stored record |
| + round-trips its kind and channel. |
| +- The same body at a sibling app's apex is 409. |
| +- A host outside the manifest domain is 400. |
| + |
| +End-to-end, through the existing fake-console harness: the three calls in |
| +order, and the printed lines. Every test that shells out to the binary |
| +builds its environment through `carlosTestEnv` with a scratch `cmd.Dir` — |
| +the rule this repo paid for with 40 real accounts on the production |
| +console. |
| + |
| +Live: a throwaway static app deployed on flagship and verified with `curl |
| +-sI --resolve` for `X-Carlos-Version`, using the binary built from this |
| +branch. |
| + |
| +## 8. Follow-ups filed, not fixed |
| + |
| +- A record's channel is unchangeable through any door — the second half of |
| + tonight's work, spec'd separately. |
| +- "Instance" is the wrong member-facing word for a static route. |