carlos / platform Public

spec: domain attach — self-serve custom domains (#82), converged with #151

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell fa6d189e05453d6b66a9efd3e1d449a2e214b3d2 parent 4dea961
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.