spec: domain attach — self-serve custom domains (#82), converged with #151
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+238
−0
docs/superpowers/specs/2026-08-12-domain-attach-design.md+238 −0
diff --git a/docs/superpowers/specs/2026-08-12-domain-attach-design.md b/docs/superpowers/specs/2026-08-12-domain-attach-design.md| new file mode 100644 |
| index 000000000..f9f75abf4 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-12-domain-attach-design.md |
| @@ -0,0 +1,238 @@ |
| +# Domain attach — self-serve custom domains from console, CLI and deploy |
| + |
| +**Date:** 2026-08-12 · **Status:** draft (design approved in conversation |
| +2026-08-12; alias mechanism re-converged the same evening with the #151 |
| +session — see §1.1) · **Requested by:** Paul |
| + |
| +**Closes:** #82 (console-driven custom domain attach). |
| + |
| +**Builds on:** route records (2026-08-10 — the declared-record vocabulary |
| +and reconcile authority; PR #137), #151 (domain claim records + |
| +`route_aliases` + the `domains` console door + `carlos domains` verbs — the |
| +alias half of this design is #151's mechanism, consumed as-is), custom |
| +domains (2026-08-02 — the DNS stamps, the §8 future-work list this |
| +retires a line from), console app pages (2026-08-07 §6 — the Settings |
| +honesty text this replaces), and `carlos deploy` (2026-08-08 — the |
| +one-command form §5 completes). |
| + |
| +## 1. Why, and the shape in one paragraph |
| + |
| +Attaching a custom domain to an app is an operator act today: `carlos add` |
| +on the box over SSM, which is the exact class of gap CLAUDE.md's no-infra |
| +rule names. Two live incidents prove both halves: lazyatom's |
| +echo.example.com (alias attach to a running app — refused by every member |
| +door) and story.carlosframework.com (a fresh static app whose FIRST route |
| +is the custom domain — took an operator route-add plus manual DNS). The |
| +self-serve path splits cleanly on one question the console can answer at |
| +attach time: **does the app already have a ready route?** |
| + |
| +- **Yes → alias attach** (#151's mechanism): the door writes a global |
| + claim record naming the primary route; a box-side reconcile pass |
| + derives a `route_aliases` entry from it, and the edge resolves the |
| + alias to the primary route's upstream at request time. No second route |
| + row exists, which is what avoids the #129 socket-race class, #148's |
| + bare-alias breakage, and the wake-through-aliasing hole (custom-domains |
| + §8) that a copied-shape row inherits. Alias→static-primary works by |
| + #151's pinned rule — content/backing derivation uses the primary's |
| + host; the request host is TLS/cache-key/metering only — so an alias of |
| + a static route serves the same channel-pointed content and carries |
| + `X-Carlos-Version`. |
| +- **No → first-route attach** (this spec's one new mechanism): the same |
| + global claim, plus a **declared route record** in #137's vocabulary — |
| + `kind: static` + `channel`, validated by `ValidHostShape` — which the |
| + provisioning reconciler materializes as the app's real primary route. |
| + There is no source to alias, so nothing competes with `route_aliases`; |
| + the minted route IS the primary. |
| + |
| +Everything else — Settings UI, `carlos deploy -host` auto-attach, and the |
| +policy rulings — rides those two mechanisms through one console door. |
| + |
| +### 1.1 The convergence note for Paul's morning read |
| + |
| +Two designs were approved in separate sessions on 2026-08-12: this |
| +session's original approach (attach = a route record with an additive |
| +`alias: true` field; reconcile mints a second route row copying the |
| +primary's upstream trio) and the #151 session's (global claim + |
| +`route_aliases`, no second row). They conflicted. The second-row shape |
| +loses on the merits — it reproduces the #129 socket race for hibernating |
| +instances, re-imports #148's alias breakage, and keeps the |
| +502-while-hibernating hole — and its one advantage (no new registry |
| +table) is not worth those. The per-app record's uniqueness check also |
| +loses to the global claim: scanning records across accounts is racy where |
| +a global create-only key is atomic. This spec is the reconciliation: #151 |
| +builds the alias mechanism and the shared door; #82 adds the first-route |
| +mechanism, the UI, and deploy integration on top. The `alias: true` |
| +record field from the earlier in-session approval is **dead** — it |
| +appears nowhere below. |
| + |
| +## 2. Rulings (decided 2026-08-12, in-session) |
| + |
| +- **Route-only.** An attached domain serves the app; it does not widen |
| + instance space. `provision.ValidHost` and the sqid-space pin on |
| + `carlos instances create` / `handleAPIInstanceCreate` are untouched. |
| + An app whose model is instance-per-tenant still mints instances under |
| + `<sqid>.<apps-domain>` only. Widening the manifest's `Domain` to "any |
| + attached domain" is a named follow-up issue, not an oversight. |
| +- **DNS-pointing is the ownership evidence.** A claim whose DNS does not |
| + reach the edge yields an inert alias or an unroutable route; the |
| + console's existing `pointing`/`elsewhere` stamp is the signal, and |
| + first-writer-wins (the global claim) closes the intra-platform race. |
| + Residual risk, stated: a domain whose DNS already points at the edge |
| + but is unclaimed could be claimed by an account that does not control |
| + it. Narrow window, accepted for this slice. A TXT-challenge |
| + (`_carlos-challenge.<host>`) pending state is named future work, gated |
| + on abuse actually appearing once Carloku is commercial — the claim |
| + record grows a field then; nothing structural changes. |
| +- **No DNS record automation.** The door tells the member exactly which |
| + records to create (edge IPs / CNAME target) and stamps progress; it |
| + does not write to any registrar. The story incident's DNSimple half |
| + stays manual by design — token scopes differ per zone and no |
| + self-serve story exists yet. |
| + |
| +## 3. First-route attach — the one new mechanism |
| + |
| +Console-side only; the box half already exists in #137. |
| + |
| +When the app has no ready route and the attach names `kind: static`, the |
| +door writes, in order: |
| + |
| +1. The **global claim** (`domains/<host>.json`, create-only, via #151's |
| + shared claim writer). The claim shape is #151's: |
| + `{schema: 1, host, account, app, route, created_at, created_by}` — |
| + `route` names the primary route host in the alias case, and an |
| + **empty `route` means "this host is its own primary"**, which is |
| + exactly this case. Loss of the create = neutral refusal (§4). |
| +2. A **declared route record** at `apps/<sqid>/<app>/instances/<host>.json` |
| + in #137's vocabulary: `kind: static`, `channel` (explicit, or the |
| + app's only shipped channel — several channels and no flag is refused |
| + as ambiguous, naming them), `pool`/`region` empty (the shared pool, |
| + unconstrained region — the deployment's default boxes), plus |
| + `app`/`account`/`added_at`/actor audit fields the record already |
| + carries. |
| +3. `bumpSerial` — the route exists fleet-side in seconds, #137's normal |
| + materialization path, zero new reconcile code. |
| + |
| +A proxied kind (`instance`, `service`) with no existing route is refused: |
| +there is no process, socket or address to serve, so the attach would mint |
| +a route to nothing. The message says to `carlos deploy` (or enable |
| +instances) first — except through §5's deploy integration, where the ship |
| +happens in the same command and the static case is the only one that |
| +arises. |
| + |
| +Detach = delete the route record and release the claim (same door, |
| +reverse order); #137's `Provisioned`-keyed removal phase drops the route. |
| +If the record was adopted rather than minted (backfill, §7), removal |
| +authority is identical — that is #137's contract. |
| + |
| +## 4. The door, shared with #151 |
| + |
| +One endpoint family (built by #151, extended here): |
| + |
| +``` |
| +POST /api/cli/apps/{account}/{app}/domains/{host} attach |
| +GET /api/cli/apps/{account}/{app}/domains list + stamps |
| +DELETE /api/cli/apps/{account}/{app}/domains/{host} detach |
| +``` |
| + |
| +Standard gate ordering (session → CSRF → owner, byte-identical 404), |
| +audit entries, serial bump after every write. App-token capable (#151's |
| +requirement, inherited). |
| + |
| +For an alias attach the door's only write is the claim (with `route` set) |
| +plus the serial bump — the `route_aliases` row is derived box-side, never |
| +door-written. For a first-route attach the door writes claim then |
| +declared record (§3). Validation lives in one exported console-side spot |
| +(#151 builds it; this spec's rulings land there) so both mechanisms share |
| +it: |
| + |
| +- `ValidHostShape` — lowercase FQDN shape, no path-traversal characters; |
| + apexes allowed. |
| +- **Refuse platform space**: any host equal to or under the deployment's |
| + apps-domain, the console's own host, or an edge host. Attached domains |
| + are foreign domains by definition. |
| +- **First-writer-wins**: the global claim's create-only write is the |
| + entire uniqueness check. A lost create — whatever account holds the |
| + claim — returns the same neutral "host unavailable" with no owner |
| + leaked. A host that is routed on some box but unclaimed (operator |
| + routes predating this) is handled box-side, not console-side: reconcile |
| + finds a differing existing route, leaves it alone, and logs — inert, |
| + diagnosable, no takeover (#137 §4.2's rule doing its job at this door). |
| + |
| +The GET carries the DNS stamp per host (`pointing` / `elsewhere`, the |
| +existing Domains-table machinery pointed at attached hosts) plus what to |
| +create: A/AAAA targets for an apex, CNAME target otherwise. |
| + |
| +## 5. `carlos deploy -host` completes the story |
| + |
| +`carlos deploy -kind static -host story.carlosframework.com` on a fresh |
| +app becomes the whole job: |
| + |
| +1. Resolve the host's state via the door's GET: claimed by this app → |
| + proceed; claimed by another → refuse (neutral); unclaimed → attach |
| + (alias if the app has a ready route, first-route declared record if |
| + static, exactly §3's rules — `-kind static` supplies the kind, the |
| + shipped channel supplies the channel). |
| +2. Ship → promote, unchanged. |
| +3. Verify: wait on `X-Carlos-Version` over 2xx as deploy already does. |
| + If DNS is not pointing yet, say so honestly and print the records to |
| + create plus the stamp state, then exit nonzero with "re-run when DNS |
| + points, or watch with `carlos domains -app X`" — never spin against a |
| + host whose DNS the member has not created, and never claim success. |
| + |
| +`carlos domains attach/detach/list` themselves are #151's verbs; deploy |
| +calls the same client functions. Attach output always ends with the DNS |
| +records to create and the current stamp — the command completes the job |
| +or says exactly what is left, per the no-infra rule. |
| + |
| +## 6. Settings UI |
| + |
| +The app Settings page swaps the "custom domains attach via carlos add on |
| +the box" honesty text for the real thing: an attach form (host; kind |
| +fixed to what the app supports — alias for routed apps, static |
| +first-route otherwise), the attached-domains list with DNS stamps and |
| +detach, and the DNS records to create shown inline per pending host. |
| +Confirm-before-detach; no fresh-auth gate (attach is reversible and |
| +serves the member's own app; detach of a live domain gets the confirm, |
| +not a password). Empty state names the platform domain the app already |
| +has, so attach reads as addition, not rescue. |
| + |
| +## 7. Backfill |
| + |
| +story.carlosframework.com (and any other operator-added custom-domain |
| +route): write the claim + the declared record describing the route that |
| +already exists; #137's adopt-on-match stamps `Provisioned` and takes |
| +ownership; the SSM-added row becomes a record-owned row with no box |
| +command. Applied one host at a time, verified through the serial, per |
| +#137 §4.3's discipline. |
| + |
| +## 8. Rollout and testing |
| + |
| +**Order:** #151 lands first on main (its own rollout). #137 merges |
| +(one-click, catch-up merge pushed 2026-08-12; merging is Paul's action). |
| +This branch stacks on `worktree-route-records` and rebases to main when |
| +#137 lands; console + CLI ship together, and since every box-side |
| +mechanism pre-exists (#137's reconcile, #151's alias pass), the door |
| +opening is the only event — no window where records outrun readers. |
| + |
| +**Testing:** |
| +- Door: gate-ordering tests (byte-identical 404), each refusal (shape, |
| + platform space, claim held, ambiguous channel, proxied-kind-no-route), |
| + claim-then-record write order, serial bump, audit entries. |
| +- First-route: record materializes via existing reconcile tests' harness; |
| + detach removes; adopt-on-match backfill golden for the story shape. |
| +- Deploy: auto-attach happy path, DNS-not-pointing honest failure, claim |
| + -held refusal; e2e through `carlosTestEnv` with `cmd.Dir` scratch. |
| +- Parse goldens: claim and record shapes byte-stable against pre-change |
| + writers (the additive rule). |
| + |
| +## 9. Out of scope, named |
| + |
| +- **Instance-space widening** (attached domain as instance space) — the |
| + route-only ruling's follow-up issue. |
| +- **TXT ownership challenge** — future work gated on abuse (§2). |
| +- **Bring-your-own apps domain** (custom-domains §8 bullet 1) — the |
| + per-account ACME and DNS-delegation price tag stays there, unpaid. |
| +- **DNS record automation** — no registrar writes (§2). |
| +- **Path routing** — one host, one upstream, still. |
| +- **Proxied first-route attach** — an app with no process gets no |
| + proxied route; deploy-first stays the answer. |