carlos / platform Public

spec: route extra hostnames — attach/detach design (#151)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 53e293520762a84a262cdfb462cca3afa990fe14 parent a73539f
1 file changed, +288 −0
  • docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md +288 −0
diff --git a/docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md b/docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md
new file mode 100644
index 000000000..e6cb277d8
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md
@@ -0,0 +1,288 @@
+# Route extra hostnames (attach/detach) — design record
+
+Date: 2026-08-12. Issue: #151 (titogo custom domains, the CARLOS edge half —
+titogo repo `docs/superpowers/specs/2026-08-11-custom-domains-design.md` §4).
+Approved by Paul in the brainstorming session that produced this document.
+
+Companion division of labor (agreed cross-session, recorded in §10): #151
+lands the whole alias mechanism, API door, and CLI; #82 later rides the same
+door for console UI, deploy auto-attach, and the first-route case.
+
+## What this is
+
+Attach an extra public hostname to an existing route: the edge obtains and
+renews a certificate for it (HTTP-01, each customer domain its own Let's
+Encrypt registered domain, so rate limits are per-customer), proxies it to
+the same backend, and wakes hibernated instances identically to the route's
+primary hostname. Detach removes it. Both verbs are idempotent — the titogo
+router retries them on its reload passes.
+
+Surface: a first-class member feature — console API door + `carlos domains`
+CLI verb — which the titogo router also calls with its app token. Not a
+titogo-only side door.
+
+## Decisions made in the session
+
+- **Member feature + CLI**, not a minimal titogo-shaped door.
+- **Ownership proof = DNS pointing at the edge.** Attach validates shape,
+ uniqueness, and the platform-domain denylist; the hostname only certs and
+ serves when its DNS actually resolves to us (HTTP-01 succeeding is the
+ gate). No platform TXT challenge in v1 — titogo's richer claim
+ verification stays titogo's.
+- **Alias table + edge map, not a second routes row.** The #82 sketch
+ (mint a full copied route row) reproduces the #129 socket-race class for
+ hibernating instances, the #148 bare-alias breakage for unit-backed
+ routes, and copy-drift when sockets move. Titogo's registry made the same
+ call for the same reasons. The #82 session has conceded its second-row
+ materialization to this mechanism (§10).
+- **Build on main, not the #137 branch.** The alias record is a sibling of
+ `instances/<host>.json`, not a field inside it — #137's spec (§9)
+ explicitly scoped this territory out. #137 is a design precedent here
+ (record → reconcile → derived state), not a code dependency.
+
+## Architecture
+
+### 1. Desired state: one global claim record per hostname
+
+Bucket key `domains/<host>.json`:
+
+```json
+{
+ "schema": 1,
+ "host": "tickets.example.com",
+ "account": "<sqid>",
+ "app": "tito",
+ "route": "<primary-route-host>",
+ "created_at": "...",
+ "created_by": "<address or app-token label>"
+}
+```
+
+The key is global, so create-only CAS **is** the cross-account uniqueness
+check — no racy list-then-verify. `route` names the primary route this
+hostname aliases. An empty/omitted `route` is reserved (additively) for
+#82's first-route case: "this host is, or will be, its own primary route."
+The #151 reconcile pass ignores routeless records entirely.
+
+Wire-shape rules apply from day one: additive only; anything written before
+a change must still parse.
+
+Attach = CAS create (or converge `route` if the same owner re-attaches);
+detach = owner-verified delete. The claim-writing helper is exported so
+#82's first-route path writes claims through the same code.
+
+### 2. Registry: a wholly derived `route_aliases` table
+
+```
+route_aliases(
+ alias TEXT PRIMARY KEY,
+ host TEXT NOT NULL, -- the primary route
+ account TEXT NOT NULL,
+ app TEXT NOT NULL,
+ dns_status TEXT,
+ dns_checked_at TEXT,
+ cert_expires_at TEXT,
+ created_at TEXT
+)
+```
+
+Idempotent CREATE in `Registry.migrate()`. Every row is derived from a claim
+record by the reconcile pass — nothing else writes the table, so no
+`Provisioned`-style ownership flag is needed: the pass owns all rows.
+
+`Fingerprint()` grows a **separate second query/hash section** for
+`route_aliases` — the existing routes SELECT line is not edited. (Agreed
+with the HA session so #152's `addrs`/`health` column additions to that
+line cannot collide.)
+
+### 3. Box reconcile: a small pass in the fast loop
+
+New pass (beside `internal/domains`): list `domains/` records; for each
+record with a non-empty `route`:
+
+- if the local registry has that route **and its `account`/`app` columns
+ match the record** — this is the authoritative ownership gate — upsert
+ the alias row;
+- if the alias equals an existing `routes.host`, skip and log: real routes
+ always win over aliases;
+- boxes not carrying the primary route skip the record.
+
+Alias rows with no backing record are deleted. The pass rides the agent's
+serial-triggered `fullPass` (2s convergence) with the hourly adopt-timer
+floor beneath it, like every other pass.
+
+DNS and cert-expiry stamping for aliases rides the existing periodic
+`internal/domains` reconcile (same `SetDNSStatus` / acme-cache-file
+helpers, applied to `route_aliases` rows).
+
+Store-door boxes: `internal/edge/storedoor.go` gains an `aclRead` case
+granting fleet boxes read-only access to the `domains/` prefix, so the pass
+works there too (direct-S3 platform boxes are unaffected).
+
+### 4. Edge: an alias resolves to the primary's upstream entry
+
+In `Reload()`, after the host map is built from routes: for each alias
+whose primary host has an entry, the alias key maps to **the identical
+upstream entry** the primary uses — the same value, never a copied `Addr`,
+never a rebuilt proxy — marked as an alias. Consequences, all by
+construction:
+
+- **Hibernation wake is identical**: the activator is keyed on the primary
+ `Route.Host` at every layer (actEntry, lease, live-symlink, cgroup), so a
+ request arriving on the alias wakes the same single instance — no
+ #129-class second wake racing on one socket.
+- **TLS needs nothing new**: `Allowed()` walks the live map, so the alias
+ is cert-eligible; per-hostname HTTP-01 mints on first handshake.
+- **Wildcards are untouched**: alias entries are explicitly skipped in
+ wildcard parent derivation (`currentHosts`) — customer domains wouldn't
+ match the platform shape anyway, but the exclusion is stated, not
+ incidental.
+- **Static primaries work**: all content/backing derivation uses
+ `route.Host` (the primary); the request Host is used only for TLS,
+ cache keying, and usage metering. An alias on a static route serves the
+ primary's channel-pointed content and carries `X-Carlos-Version`. (Rule
+ pinned by test — it is what makes #82's `deploy -kind static -host` on
+ an already-routed app a pure alias attach.)
+- **HA composes** (#152): when multi-upstream routes land, the primary's
+ entry carries the addr set and prober state; aliases inherit failover
+ with zero alias-side changes because they share the entry. Pinned by a
+ test asserting alias and primary resolve to the same entry.
+- **Usage metering** (unit-economics branch): the edge meters per request
+ Host, so an attached domain counts as its own row, attributed to the
+ same route/app. Intended.
+
+### 5. Console door: the `/domains` family
+
+```
+GET /api/cli/apps/{account}/{app}/domains → list
+POST /api/cli/apps/{account}/{app}/domains → attach {host, route?}
+DELETE /api/cli/apps/{account}/{app}/domains/{host} → detach
+```
+
+Auth follows `apiInstancePrincipal` exactly: an app token bound to that
+account+app, or a member's user token. The app-token allowlist grows from
+"instances" to "instances + domains" (`authcli.go`'s registered family).
+Rate limiting keys by token hash, the instances precedent.
+
+Validation, in one exported spot (so #82 adds its rulings there):
+
+- well-formed lowercase ASCII FQDN — no port, no wildcard label, trailing
+ dot stripped; IDN/punycode out of scope (titogo's v1 rule, adopted);
+- denylist: the apps domain and any host under it, the console's own
+ origin host, and the edge's reserved tunnel hostname — all derived from
+ console/deployment config, not hardcoded;
+- `route` must name a route of this app — best-effort at the door
+ (instance records; if the app has exactly one instance, `route` may be
+ omitted and defaults to it), authoritative at reconcile (§3). Operator
+ routes with no instance record (tito's) pass the door and are gated on
+ box.
+
+Idempotency (the titogo contract):
+
+- attach, already attached by the same owner → 200, converging `route` if
+ it changed;
+- attach, owned by someone else → 409 with a neutral "hostname
+ unavailable" — never who owns it;
+- detach, unknown hostname → quiet 200;
+- detach, owned by someone else → quiet 200, no action taken.
+
+Every successful write audits and bumps `control/serial` (strictly after
+the write), so boxes converge in ~2s.
+
+### 6. CLI: `carlos domains`
+
+```
+carlos domains attach -app tito [-route <primary-host>] tickets.example.com
+carlos domains detach -app tito tickets.example.com
+carlos domains list -app tito
+```
+
+`list` shows each hostname's primary route, DNS status, and cert expiry —
+the command alone verifies the result, per the no-infra rule. Status
+source: the box readings sweep (the #144 machinery) grows an additive
+aliases section carrying each alias row's stamped `dns_status` /
+`cert_expires_at`; the console door merges it into the claim listing. A
+claim no rolled box has materialized yet lists with status `pending`.
+Standard `apiSession` plumbing (`apimode.go` wire methods + a
+`domains_cli.go` verb file).
+
+### 7. Titogo wiring
+
+Nothing new to build platform-side for delivery. The router reads
+`TITO_EDGE_ATTACH_URL` (set to this door's collection URL,
+`https://<console>/api/cli/apps/<sqid>/tito/domains`) and reuses
+`CARLOS_INSTANCE_TOKEN` as its bearer. Both deliver through the existing
+`carlos env set -app tito [-secret]` → `SyncExec` → `<host>.config.env`
+chain the router already consumes (`ExecDelivery: "file"`).
+
+Note for the record: `CARLOS_INSTANCE_URL` is a titogo-side env name; the
+platform's contribution is the door URL shape, documented when closing
+the issue.
+
+## Error handling
+
+- Primary route missing or not ready → alias skipped at reload with a log
+ line; never a 502 route to nowhere.
+- Record whose route never appears → sits inert. This is also the rollout
+ property: old box binaries ignore `domains/` entirely, so the console
+ door can open before boxes roll; records materialize when boxes catch up.
+- DNS not yet pointed → HTTP-01 fails, autocert retries on later
+ handshakes; `carlos domains list` shows `dns_status` so the caller sees
+ why.
+- CAS race on simultaneous attach → one winner; the loser gets the neutral
+ 409.
+
+## Testing
+
+- **Registry**: alias CRUD, migrate idempotence, fingerprint's second
+ section changes when aliases change and is byte-stable when they don't.
+- **Reconcile**: materialize on match; account/app mismatch refused;
+ routes-win collision skip; record-gone removal; routeless (`route: ""`)
+ records ignored; multi-record convergence.
+- **Edge**: alias serves the primary's backend; alias and primary share
+ the same upstream entry (the #152 composition pin); `Allowed()` admits
+ the alias; hibernating primary wakes exactly once via the alias (the
+ #129-class regression test); wildcard parent derivation skips aliases;
+ static primary's alias serves content + `X-Carlos-Version`.
+- **Console**: auth matrix (app token right/wrong app, member, non-member
+ byte-identical 404); the four idempotency cases; denylist; CAS race;
+ serial bump after write.
+- **CLI**: attach/detach/list round-trip against a fake console; env built
+ through `carlosTestEnv` with `cmd.Dir` in scratch (the minted-accounts
+ rule).
+
+## Rollout
+
+One PR off main (worktree `route-hostnames`; the primary checkout's
+working tree belongs to the unit-economics session and is untouched).
+Deploy in the usual roll: box binaries + consoles; ordering is safe either
+way (additive records). After deploy: confirm/mint the tito app token,
+`carlos env set` the URL + token, close #151, and notify the titogo agent
+so its PR 5 (the attach/detach client) can proceed.
+
+## Cross-session coordination (recorded 2026-08-12, evening)
+
+- **unit-economics** (same checkout, branch `unit-economics`): touching
+ `internal/edge/edge.go` (metering wrap in ServeHTTP) and console route
+ registrations. Different hunks; whoever lands second rebases. Alias
+ hosts metering as their own rows is intended (§4).
+- **HA / #152 #153** (PR #154, base includes #137): three agreements —
+ alias shares the primary's upstream entry identity; `route_aliases`
+ fingerprints as a separate section; store-door `aclRead` for `domains/`.
+- **#82 session** (spec being written against this shape): concedes
+ second-row materialization; #82 rides this door for console UI +
+ `deploy` auto-attach; owns the first-route case (routeless claim +
+ #137-vocabulary instance record) on the worktree-route-records base.
+ Claim record is the single uniqueness gate for both cases, written
+ through the same helper. #82's spec names #151 as its dependency.
+- **#137** caught up to origin/main by the #82 session; merging is Paul's
+ action. #151 does not consume it.
+
+## Deliberately not in v1 (recorded)
+
+- IDN/punycode hostnames.
+- A platform-side TXT ownership challenge.
+- Migrating `domains.Reconcile`'s minted platform aliases (#148's subject)
+ onto `route_aliases` — a natural follow-up, not this change.
+- Console web UI and `carlos deploy` auto-attach — #82's territory.
+- Wildcard customer domains (`*.example.com` attach).