spec: route extra hostnames — attach/detach design (#151)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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). |