carlos / platform Public

plan: route extra hostnames implementation plan (#151)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 39900670da3a6b9f072e0e743b0a0fe0d1391293 parent 53e2935
1 file changed, +546 −0
  • docs/superpowers/plans/2026-08-12-route-extra-hostnames.md +546 −0
diff --git a/docs/superpowers/plans/2026-08-12-route-extra-hostnames.md b/docs/superpowers/plans/2026-08-12-route-extra-hostnames.md
new file mode 100644
index 000000000..bf2ba15b6
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-12-route-extra-hostnames.md
@@ -0,0 +1,546 @@
+# Route Extra Hostnames (#151) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Attach/detach an extra public hostname on an existing route: global claim record in the bucket → derived `route_aliases` registry table → edge serves the alias off the primary route's own upstream entry, with HTTP-01 certs, identical hibernation wake, a console API door (app-token capable), and a `carlos domains` CLI verb.
+
+**Architecture:** Desired state is one global create-only object per hostname (`domains/<host>.json`) — the bucket key is the cross-account uniqueness gate. A box reconcile pass derives `route_aliases` rows (wholly pass-owned, no ownership flag). The edge maps each alias to the primary's identical `upstream` value at Reload. Console door writes claims + bumps `control/serial`; boxes converge in ~2s.
+
+**Tech Stack:** Go stdlib, modernc.org/sqlite (registry), existing `release.Store` bucket abstraction. No new dependencies.
+
+**Spec:** `docs/superpowers/specs/2026-08-12-route-extra-hostnames-design.md` — read it first.
+
+## Global Constraints
+
+- Worktree: `/home/paulca/github.com/carlosframework/platform/.claude/worktrees/route-hostnames`, branch `worktree-route-hostnames`. NEVER touch the primary checkout (another session owns it).
+- Go commands need: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`, and may need the sandbox disabled ("read-only file system" is the tell).
+- Before any commit: the touched packages' tests pass; before the final commit: `go build ./... && go vet ./... && go test ./... -count=1` all green, `gofmt -l` clean for touched files, and `GOOS=darwin go build ./...` compiles (the CLI runs on Macs).
+- Wire shapes are additive: claim schema is 1; refuse `Schema > 1` on read (the `GetManifestETag` precedent); never repurpose a field.
+- `Fingerprint()` additions go in a SEPARATE second query/hash section — do NOT edit the existing routes SELECT (agreed with the HA session; their #152 columns land on that line).
+- Do NOT hash `dns_status` / `dns_checked_at` / `cert_expires_at` into the fingerprint — stamping runs every 5 minutes and would otherwise force a pointless edge reload each pass (the routes section deliberately omits the same columns).
+- The alias's `upstream` entry must be the primary's identical value (shared `proxy` handler), never a rebuilt proxy or copied Addr — #152 composition depends on it.
+- Neutral errors only for cross-tenant probes: "hostname unavailable" (409), never who owns it; detach of unknown/other-owned is a quiet 200.
+- Commit after every task, message style: `feat: <what> (#151)` / `test: ...`; end commit messages with the Co-Authored-By trailer from CLAUDE.md.
+
+---
+
+### Task 1: Claim records (`internal/domains/claims.go`)
+
+**Files:**
+- Create: `internal/domains/claims.go`
+- Test: `internal/domains/claims_test.go`
+
+**Interfaces:**
+- Consumes: `release.Store` (`internal/release/store.go:42` — `Get/Put/List/Delete`), `release.ErrCASConflict`, `provision.ValidHostShape` (`internal/provision/provision.go:131`).
+- Produces (later tasks rely on these exact names):
+ - `const ClaimsPrefix = "domains/"`
+ - `func ClaimKey(host string) string` → `"domains/" + host + ".json"`
+ - `type Claim struct { Schema int; Host, Account, App, Route, CreatedAt, CreatedBy string }` (JSON tags: `schema`, `host`, `account`, `app`, `route,omitempty`, `created_at`, `created_by,omitempty`)
+ - `var ErrHostUnavailable = errors.New("hostname unavailable")`
+ - `func AttachClaim(ctx context.Context, s release.Store, c Claim) (created bool, err error)`
+ - `func DetachClaim(ctx context.Context, s release.Store, host, account, app string) (removed bool, err error)`
+ - `func GetClaim(ctx context.Context, s release.Store, host string) (*Claim, error)`
+ - `func ListClaims(ctx context.Context, s release.Store) ([]Claim, error)`
+ - `func NormalizeAliasHost(raw string) (string, bool)` — lowercase, strip one trailing dot, then require `provision.ValidHostShape` AND at least one interior dot (a bare label is never a public hostname)
+ - `func HostDenied(host string, deny []string) bool` — true when host equals any deny entry or ends in `"."+entry` (empty entries skipped)
+
+- [ ] **Step 1: Write the failing tests** — `internal/domains/claims_test.go`, using `release.NewDirStore(t.TempDir())` (the `serial_test.go` precedent). Cases:
+
+```go
+func TestAttachClaimCreates(t *testing.T) // fresh attach: created=true, GetClaim round-trips all fields
+func TestAttachClaimIdempotent(t *testing.T) // same owner, same route: created=false, nil error, object unchanged
+func TestAttachClaimConvergesRoute(t *testing.T) // same owner, new route: created=false, stored Route updated
+func TestAttachClaimOtherOwnerRefused(t *testing.T) // different account or app: errors.Is(err, ErrHostUnavailable), object unchanged
+func TestAttachClaimNewerSchemaRefused(t *testing.T) // stored {"schema":2}: error mentioning schema, object unchanged
+func TestDetachClaimRemoves(t *testing.T) // owner detach: removed=true, GetClaim → os.ErrNotExist
+func TestDetachClaimUnknownQuiet(t *testing.T) // no claim: removed=false, nil error
+func TestDetachClaimOtherOwnerQuiet(t *testing.T) // other owner: removed=false, nil error, object still there
+func TestListClaimsSkipsGarbage(t *testing.T) // a domains/ key with invalid JSON is skipped, valid ones returned
+func TestNormalizeAliasHost(t *testing.T) // "Tickets.Example.COM." → "tickets.example.com", ok; "localhost" (no dot) → !ok; "a..b" → !ok; "" → !ok; 254 chars → !ok
+func TestHostDenied(t *testing.T) // ("x.oncarlos.com", ["oncarlos.com"]) true; ("oncarlos.com", same) true; ("xoncarlos.com", same) false; empty deny entry never matches
+```
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/domains/ -run 'Claim|AliasHost|HostDenied' -count=1`. Expected: compile error (`claims.go` doesn't exist).
+
+- [ ] **Step 3: Implement `claims.go`.** AttachClaim semantics (comment them in the file):
+
+```go
+// AttachClaim writes host's global claim, create-only by key: the bucket
+// key IS the cross-account uniqueness gate. Same owner re-attaching is
+// idempotent (converging Route in place); any other owner gets
+// ErrHostUnavailable — deliberately neutral, never naming the holder.
+func AttachClaim(ctx context.Context, s release.Store, c Claim) (bool, error) {
+ c.Schema = 1
+ body, err := json.Marshal(c)
+ if err != nil {
+ return false, err
+ }
+ for attempt := 0; ; attempt++ {
+ cur, etag, err := getClaimETag(ctx, s, c.Host)
+ if errors.Is(err, os.ErrNotExist) {
+ if _, err := s.Put(ctx, ClaimKey(c.Host), body, ""); err == nil {
+ return true, nil
+ } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 {
+ continue // lost the create race — re-read and judge the winner
+ } else {
+ return false, err
+ }
+ } else if err != nil {
+ return false, err
+ }
+ if cur.Account != c.Account || cur.App != c.App {
+ return false, ErrHostUnavailable
+ }
+ if cur.Route == c.Route {
+ return false, nil
+ }
+ if _, err := s.Put(ctx, ClaimKey(c.Host), body, etag); err == nil {
+ return false, nil
+ } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 {
+ continue // converge race — one retry, then surface
+ } else {
+ return false, err
+ }
+ }
+}
+```
+
+`getClaimETag` is the unexported Get+parse (refusing `Schema > 1` with the `GetManifestETag`-style error); `GetClaim` wraps it. `DetachClaim`: get; `os.ErrNotExist` → `(false, nil)`; owner mismatch → `(false, nil)`; else `s.Delete` → `(true, nil)`. `ListClaims`: `s.List(ctx, ClaimsPrefix)`, parse each, skip unparseable/newer-schema with a `log.Printf`. `NormalizeAliasHost`: `strings.ToLower`, `strings.TrimSuffix(h, ".")` once, then `provision.ValidHostShape(h) && strings.Contains(h, ".")`.
+
+- [ ] **Step 4: Run to verify pass** — same command. Expected: PASS.
+- [ ] **Step 5: Commit** — `git add internal/domains/claims.go internal/domains/claims_test.go && git commit -m "feat: global hostname claim records (#151)"`.
+
+---
+
+### Task 2: Registry `route_aliases` table
+
+**Files:**
+- Modify: `internal/registry/registry.go` — `migrate()` (after the routes ALTER block, `registry.go:291-360`), `Fingerprint()` (append a section AFTER the `fleet_boxes` section, `registry.go:1277-1295`)
+- Test: `internal/registry/registry_test.go` (append)
+
+**Interfaces:**
+- Produces:
+ - `type RouteAlias struct { Alias, Host, Account, App, DNSStatus string; DNSCheckedAt, CertExpiresAt, CreatedAt int64 }`
+ - `func (r *Registry) ListAliases() ([]RouteAlias, error)` (ordered by alias)
+ - `func (r *Registry) UpsertAlias(a RouteAlias) error` — `ON CONFLICT(alias) DO UPDATE SET host,account,app` only (stamps survive re-upsert; CreatedAt kept from first insert)
+ - `func (r *Registry) DeleteAlias(alias string) error` (idempotent)
+ - `func (r *Registry) SetAliasDNSStatus(alias, status string, at int64) error`
+ - `func (r *Registry) SetAliasCertExpiresAt(alias string, at int64) error`
+
+- [ ] **Step 1: Write the failing tests** (follow the file's existing open-registry helper):
+
+```go
+func TestRouteAliasCRUD(t *testing.T) // upsert, list round-trip, re-upsert repoints host but keeps stamps+created_at, delete idempotent
+func TestRouteAliasStamps(t *testing.T) // SetAliasDNSStatus / SetAliasCertExpiresAt persist and survive UpsertAlias
+func TestFingerprintCoversAliases(t *testing.T) // fp1; UpsertAlias → fp2 != fp1; SetAliasDNSStatus → fp3 == fp2 (stamps NEVER move the fingerprint); DeleteAlias → fp4 != fp2
+func TestMigrateIdempotentWithAliases(t *testing.T) // open the same db path twice; second open succeeds
+```
+
+- [ ] **Step 2: Verify failure** — `go test ./internal/registry/ -run 'Alias|FingerprintCovers' -count=1`. Expected: compile error.
+- [ ] **Step 3: Implement.** DDL in `migrate()`:
+
+```go
+`CREATE TABLE IF NOT EXISTS route_aliases (
+ alias TEXT PRIMARY KEY,
+ host TEXT NOT NULL,
+ account TEXT NOT NULL DEFAULT '',
+ app TEXT NOT NULL DEFAULT '',
+ dns_status TEXT NOT NULL DEFAULT '',
+ dns_checked_at INTEGER NOT NULL DEFAULT 0,
+ cert_expires_at INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL DEFAULT 0
+)`
+```
+
+Fingerprint addition — a new section appended after `fleet_boxes`, same shape (comment why stamps are excluded, mirroring the routes section's own omission):
+
+```go
+fmt.Fprint(h, "\x1droute_aliases\x1e")
+arows, err := db.Query(`SELECT alias, host, account, app FROM route_aliases ORDER BY alias`)
+// scan alias, host, account, app; Fprintf "%s\x1f%s\x1f%s\x1f%s\x1e"
+```
+
+- [ ] **Step 4: Verify pass** — same command, then `go test ./internal/registry/ -count=1` (whole package: the fingerprint golden tests must still pass — if an existing test pins an exact digest for an empty registry, update it once, stating why in the test comment).
+- [ ] **Step 5: Commit** — `feat: derived route_aliases registry table (#151)`.
+
+---
+
+### Task 3: Alias reconcile pass (`internal/domains/aliases.go`)
+
+**Files:**
+- Create: `internal/domains/aliases.go`
+- Test: `internal/domains/aliases_test.go`
+
+**Interfaces:**
+- Consumes: Task 1 (`ListClaims`, `Claim`), Task 2 (`ListAliases/UpsertAlias/DeleteAlias`, `RouteAlias`), `release.RouteAccount(rt)` (the resolver `domains/reconcile.go:396` already uses), `registry.Registry.List()`.
+- Produces: `func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time) error`
+
+- [ ] **Step 1: Failing tests** — `release.NewDirStore` + a temp registry with seeded routes:
+
+```go
+func TestReconcileAliasesMaterializes(t *testing.T) // claim {route: R owned by acct/app matching R's row} → alias row appears with account/app/created_at
+func TestReconcileAliasesOwnershipGate(t *testing.T) // claim whose account or app differs from the route row's → NO row, pass returns nil
+func TestReconcileAliasesRoutesWin(t *testing.T) // claim whose host equals an existing routes.host → NO row
+func TestReconcileAliasesMissingRouteSkips(t *testing.T) // claim naming an absent route → NO row, no error
+func TestReconcileAliasesRemovesOrphans(t *testing.T) // pre-seeded alias row with no claim → deleted
+func TestReconcileAliasesRouteless(t *testing.T) // claim with Route:"" (the #82 first-route case) → ignored entirely
+func TestReconcileAliasesIdempotent(t *testing.T) // second run: same rows, CreatedAt unchanged
+```
+
+- [ ] **Step 2: Verify failure.** `go test ./internal/domains/ -run ReconcileAliases -count=1`
+- [ ] **Step 3: Implement:**
+
+```go
+// ReconcileAliases derives route_aliases from the global claim records.
+// The table is wholly pass-owned: every row traces to a claim, so removal
+// needs no ownership flag. The authoritative tenancy gate is HERE — the
+// door's route check is best-effort (it cannot see the registry); a claim
+// only materializes when the named route exists locally AND its
+// account/app match the claim. Routes always win a host collision: an
+// alias must never shadow a real route.
+func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Registry, now func() time.Time) error {
+ claims, err := ListClaims(ctx, s)
+ if err != nil {
+ return err
+ }
+ routes, err := reg.List()
+ if err != nil {
+ return err
+ }
+ byHost := make(map[string]registry.Route, len(routes))
+ for _, rt := range routes {
+ byHost[rt.Host] = rt
+ }
+ existing, err := reg.ListAliases()
+ if err != nil {
+ return err
+ }
+ desired := map[string]bool{}
+ for _, c := range claims {
+ if c.Route == "" { // first-route claims (#82's case) are not aliases
+ continue
+ }
+ rt, ok := byHost[c.Route]
+ if !ok {
+ continue // primary not on this box — another box's claim
+ }
+ if rt.App != c.App || release.RouteAccount(rt) != c.Account {
+ log.Printf("domains: alias %s refused: route %s belongs to %s/%s, claim says %s/%s",
+ c.Host, c.Route, release.RouteAccount(rt), rt.App, c.Account, c.App)
+ continue
+ }
+ if _, taken := byHost[c.Host]; taken {
+ log.Printf("domains: alias %s skipped: a real route owns that host", c.Host)
+ continue
+ }
+ desired[c.Host] = true
+ if err := reg.UpsertAlias(registry.RouteAlias{
+ Alias: c.Host, Host: c.Route, Account: c.Account, App: c.App,
+ CreatedAt: now().Unix(),
+ }); err != nil {
+ log.Printf("domains: upsert alias %s: %v", c.Host, err)
+ }
+ }
+ for _, a := range existing {
+ if !desired[a.Alias] {
+ if err := reg.DeleteAlias(a.Alias); err != nil {
+ log.Printf("domains: remove alias %s: %v", a.Alias, err)
+ }
+ }
+ }
+ return nil
+}
+```
+
+(CreatedAt-preservation on re-upsert comes from Task 2's ON CONFLICT clause — assert it in the idempotency test.)
+
+- [ ] **Step 4: Verify pass**, then `go test ./internal/domains/ -count=1`.
+- [ ] **Step 5: Commit** — `feat: alias reconcile pass derives route_aliases from claims (#151)`.
+
+---
+
+### Task 4: Edge serves aliases off the primary's entry
+
+**Files:**
+- Modify: `internal/edge/edge.go` — `upstream` struct (`:146`), `Reload()` (insert after the routes loop ends at `:420`, before the fleet-auth block at `:421`), `currentHosts()` (`:491`)
+- Test: create `internal/edge/alias_test.go`
+
+**Interfaces:**
+- Consumes: Task 2 `ListAliases`.
+- Produces: behavior only — no new exports. `upstream` gains `alias bool`.
+
+- [ ] **Step 1: Failing tests** (package `edge`, follow existing `edge_test.go` harness style — registry in a temp dir, `New(reg, act)`):
+
+```go
+func TestAliasServesPrimaryBackend(t *testing.T) // route ready + alias row; GET with Host=alias returns the primary's backend response
+func TestAliasSharesUpstreamEntry(t *testing.T) // e.current()[alias].proxy is the IDENTICAL handler value as [primary].proxy (the #152 composition pin: never a rebuilt proxy)
+func TestAliasAllowed(t *testing.T) // e.Allowed(alias) true; e.Allowed("stranger.example") false
+func TestAliasNeverShadowsRoute(t *testing.T) // alias row whose Alias equals another route's host: the ROUTE's backend answers
+func TestAliasSkipsMissingPrimary(t *testing.T) // alias whose Host has no ready route: unknownHost answer, Allowed false
+func TestAliasWakesPrimaryOnce(t *testing.T) // hibernating primary + fake Activator: request with Host=alias reaches act.ServeHTTP with up.route.Host == primary (never the alias) — the #129-class regression pin
+func TestCurrentHostsSkipsAliases(t *testing.T) // platform-minted primary + alias: currentHosts() contains primary, NOT the alias (wildcard derivation must never see customer domains)
+```
+
+- [ ] **Step 2: Verify failure.** `go test ./internal/edge/ -run Alias -count=1`
+- [ ] **Step 3: Implement.** `upstream` gains a field:
+
+```go
+ // alias marks an entry mapped in from route_aliases: it shares the
+ // primary's proxy/route/hibernating verbatim (so hibernation wakes the
+ // primary's actEntry and #152's per-primary prober state applies), and
+ // the flag exists so wildcard parent derivation (currentHosts) never
+ // treats a customer hostname as a platform host.
+ alias bool
+```
+
+In `Reload()`, after the routes loop and before the fleet-auth block:
+
+```go
+ // Attached hostnames (#151): each alias resolves to the primary's OWN
+ // entry — same proxy value, same route (so activator keying, version
+ // header, and purge tenancy are all the primary's). Routes always win
+ // a collision; an alias whose primary isn't in m (not ready, not
+ // local) is skipped — never a 502 route to nowhere. Errors abort the
+ // reload like every other registry read here: a transient failure must
+ // leave the old table serving.
+ aliases, err := e.reg.ListAliases()
+ if err != nil {
+ return err
+ }
+ for _, a := range aliases {
+ if _, taken := m[a.Alias]; taken {
+ log.Printf("edge: alias %s shadowed by a real route — skipped", a.Alias)
+ continue
+ }
+ up, ok := m[a.Host]
+ if !ok {
+ continue
+ }
+ up.alias = true
+ m[a.Alias] = up
+ }
+```
+
+`currentHosts()`: the filter line becomes `if up.route.Platform && !up.alias {`.
+`ServeHTTP` needs NO change (map lookup covers aliases; `up.route` is the primary's row, so the activator wake, version header, and cache `Host` keying all behave per spec §4). `managed` is untouched — the primary's own loop iteration already registered it.
+
+- [ ] **Step 4: Verify pass**, then `go test ./internal/edge/ -count=1` (the package is 16s — run it whole; hibernation and wildcard suites must stay green).
+- [ ] **Step 5: Commit** — `feat: edge serves attached hostnames off the primary route's entry (#151)`.
+
+---
+
+### Task 5: DNS + cert stamping for aliases
+
+**Files:**
+- Modify: `internal/domains/reconcile.go` — extend sections 4 (`:409-431`) and 5 (`:433-441`)
+- Test: `internal/domains/reconcile_test.go` (append; follow the existing fake-resolver pattern in this file)
+
+**Interfaces:** consumes Task 2's `SetAliasDNSStatus`/`SetAliasCertExpiresAt`; produces behavior only.
+
+- [ ] **Step 1: Failing tests:**
+
+```go
+func TestReconcileStampsAliasDNS(t *testing.T) // alias row + cfg.Resolve returning the edge IP → dns_status "pointing"; returning another IP → "elsewhere"; resolver error → "" (unknown, never a guess)
+func TestReconcileStampsAliasCert(t *testing.T) // acme dir with a cert file named exactly the alias → cert_expires_at stamped (reuse the cert fixture helper the existing TLS-health tests use)
+```
+
+- [ ] **Step 2: Verify failure.**
+- [ ] **Step 3: Implement.** In section 4, after the routes loop, iterate `reg.ListAliases()` with the same resolve-then-stamp shape (`SetAliasDNSStatus(a.Alias, status, now)`); in section 5 likewise with `hostCertExpiry(cfg.AcmeDir, a.Alias, cfg.AppsDomain)` → `SetAliasCertExpiresAt`. One `ListAliases` call shared by both sections (list once above section 4, guarded the same way — only when `cfg.Resolve != nil || cfg.AcmeDir != ""`). Log-and-continue per item, matching the route stamping.
+- [ ] **Step 4: Verify pass**, then whole package.
+- [ ] **Step 5: Commit** — `feat: DNS and cert health stamps for route aliases (#151)`.
+
+---### Task 6: Alias readings in the box snapshot
+
+**Files:**
+- Modify: `internal/status/snapshot.go` — `Probes` (`:41`), `BoxMetrics` (`:103`), `CollectBox` (`:290`)
+- Modify: `internal/host/agent.go` — the `CollectBox` call site (grep `status.CollectBox(`; populate the new field from `reg.ListAliases()`, log-and-continue on error)
+- Test: `internal/status/snapshot_test.go` (append)
+
+**Interfaces:**
+- Produces:
+ - `Probes.Aliases []registry.RouteAlias` (additive field)
+ - `BoxMetrics.Aliases map[string]AliasReading` — JSON `aliases,omitempty`
+ - `type AliasReading struct { Route string `json:"route"`; DNSStatus string `json:"dns_status,omitempty"`; CertExpiresAt int64 `json:"cert_expires_at,omitempty"` }`
+
+- [ ] **Step 1: Failing test:**
+
+```go
+func TestCollectBoxAliases(t *testing.T) // Probes.Aliases with one stamped alias → Box.Metrics.Aliases[alias] carries route/dns/cert; empty Probes.Aliases → field omitted from JSON (assert `"aliases"` absent from marshaled bytes)
+```
+
+- [ ] **Step 2: Verify failure.** `go test ./internal/status/ -run Aliases -count=1`
+- [ ] **Step 3: Implement.** In `CollectBox`, beside where `RouteVersions` is built (`:396`): map `p.Aliases` → `AliasReading`. Wire the agent call site. `status/boxes/*.json` is bucket-private (the dry-run leak scan guards the PUBLIC feed only), and nothing here feeds `BuildFleet`'s public aggregation — state that in the field's doc comment.
+- [ ] **Step 4: Verify pass** — `go test ./internal/status/ ./internal/host/ -count=1` (host package: the dry-run self-check must stay green).
+- [ ] **Step 5: Commit** — `feat: alias readings in box status snapshot (#151)`.
+
+---
+
+### Task 7: Store-door read access to `domains/`
+
+**Files:**
+- Modify: `internal/edge/storedoor.go` — `aclRead` (`:372`), `aclDir` (`:396`), and the listing-prefix switch (`:481-501`)
+- Test: `internal/edge/storedoor_test.go` (append to the existing acl table tests)
+
+- [ ] **Step 1: Failing tests** — extend the existing acl test tables: `aclRead(sqid, fleet, "domains/x.example.json")` → true; `aclRead(..., "domains")` (no slash, an object AT the name) → false; listing prefix `"domains/"` allowed; write `aclWrite(..., "domains/x.json")` → false (boxes read claims, only the console writes them).
+- [ ] **Step 2: Verify failure.** `go test ./internal/edge/ -run Acl -count=1` (match the existing test names — grep first).
+- [ ] **Step 3: Implement** — one case each in `aclRead` (`strings.HasPrefix(key, "domains/")`), `aclDir`, and the listing switch (`prefix == "domains" || prefix == "domains/"` plus `strings.HasPrefix(prefix, "domains/")`), commented as the #151 claim records fleet boxes' alias pass reads.
+- [ ] **Step 4: Verify pass.**
+- [ ] **Step 5: Commit** — `feat: store door reads domains/ claims (#151)`.
+
+---
+
+### Task 8: Agent wiring — fast leg + 5-minute floor
+
+**Files:**
+- Modify: `internal/host/agent.go` — `fullPass` (`:1118-1130`) and `instancesLoop` (`:1421+`, the `provision.Reconcile` tick)
+- Test: none new (the legs are thin-by-convention like their siblings; behavior is Task 3's)
+
+- [ ] **Step 1: Implement.** In `fullPass`, after the `reconcileSerialized` call:
+
+```go
+ if err := domains.ReconcileAliases(ctx, store, reg, time.Now); err != nil {
+ log.Printf("agent: fast converge alias pass: %v", err)
+ }
+```
+
+In `instancesLoop`'s tick, after its `provision.Reconcile`/`reconcileSerialized` call, the same call with the same log shape — this is the 5-minute floor; it must NOT live in `domainsLoop`, whose `PLATFORM_APPS_DOMAIN` inert gate would silently disable aliases on boxes that never set it. Add one line to the `fullPass` doc comment naming the new leg.
+- [ ] **Step 2: Verify** — `go build ./... && go test ./internal/host/ -count=1`.
+- [ ] **Step 3: Commit** — `feat: alias reconcile rides the fast loop and the instances floor (#151)`.
+
+---
+
+### Task 9: Console door — the `/domains` family
+
+**Files:**
+- Create: `internal/console/apidomains.go`
+- Modify: `internal/console/authcli.go` — register three routes beside the instances block (`:273-281`) and reword the "only door onto instances/" comment (`:263-266`) to "instances/ and domains/"
+- Modify: `cmd/console/main.go` — parse `CONSOLE_DENY_DOMAINS` (comma-separated) into a new `console.Config.DenyDomains []string` field (define the field in `internal/console/server.go` beside `Origin`, `:166` area)
+- Test: `internal/console/apidomains_test.go` (follow `apiinstances_test.go` harness patterns exactly, including the app-token fixtures)
+
+**Interfaces:**
+- Consumes: `instanceGate` (`apiinstances.go:103` — reused verbatim: app token bound to acct/app OR member user token, token-hash rate limit, Releases-nil 503), Task 1 (`AttachClaim/DetachClaim/ListClaims/NormalizeAliasHost/HostDenied/ErrHostUnavailable`), `provision.ValidHostShape`, `provision.ListRecords`, `status.LoadBoxes` (`internal/status/fleet.go:150`) + Task 6's `AliasReading`, `s.audit` (`server.go:1838` — `audit(acct, app, addr, action, detail)`), `s.bumpSerial`, `jsonError`, `readJSON`, `writeJSON`.
+- Produces (the CLI and titogo consume this wire shape):
+ - `POST /api/cli/apps/{account}/{app}/domains` body `{"host": "...", "route": "..."}` (route optional) → 200 `{"host","route","created":bool}`; 400 invalid host/ambiguous route; 409 `{"error":"hostname unavailable"}`; 201 is NOT used (attach is idempotent — 200 either way)
+ - `DELETE /api/cli/apps/{account}/{app}/domains/{host}` → 200 `{"host","removed":bool}` always (quiet idempotency)
+ - `GET /api/cli/apps/{account}/{app}/domains` → 200 `{"domains":[{"host","route","status","dns_status","cert_expires_at"}]}` — `status` is `"live"` when any box reading carries the alias, else `"pending"`
+
+- [ ] **Step 1: Failing tests:**
+
+```go
+func TestAPIDomainAttachCreates(t *testing.T) // member token: 200, created true; claim object exists in the test store
+func TestAPIDomainAttachAppToken(t *testing.T) // app token bound to the acct/app: 200; token bound to ANOTHER app: byte-identical 404 (the apiInstancePrincipal contract)
+func TestAPIDomainAttachIdempotent(t *testing.T) // second identical attach: 200, created false
+func TestAPIDomainAttachConflictNeutral(t *testing.T) // claim held by another account: 409, body exactly {"error":"hostname unavailable"}
+func TestAPIDomainAttachValidation(t *testing.T) // "Bad_Host!", "nodots", host under a DenyDomains entry, host == console origin host: all 400
+func TestAPIDomainAttachRouteDefault(t *testing.T) // app with exactly one instance record + no route in body: claim's Route is that record's host; two records + no route: 400 naming the flag
+func TestAPIDomainDetachQuiet(t *testing.T) // detach existing: 200 removed true (+ claim gone); detach unknown: 200 removed false; detach other-owner's: 200 removed false, claim intact
+func TestAPIDomainList(t *testing.T) // two claims for the app + a box snapshot carrying one as an alias reading: that one status "live" with dns_status, the other "pending"; another app's claim absent
+func TestAPIDomainSerialBump(t *testing.T) // attach and effective detach move control/serial; no-op detach does NOT
+```
+
+- [ ] **Step 2: Verify failure.** `go test ./internal/console/ -run APIDomain -count=1`
+- [ ] **Step 3: Implement `apidomains.go`.** Attach handler skeleton (the others follow the delete/list patterns already quoted in `apiinstances.go:560-640`):
+
+```go
+func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
+ principal, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ var in struct{ Host, Route string }
+ if !readJSON(w, r, &in) {
+ return
+ }
+ host, ok := domains.NormalizeAliasHost(in.Host)
+ if !ok {
+ jsonError(w, http.StatusBadRequest, "invalid hostname")
+ return
+ }
+ if domains.HostDenied(host, s.deniedDomains()) {
+ jsonError(w, http.StatusBadRequest, "that hostname is platform-managed")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ route, ok := s.resolveAliasRoute(ctx, w, acct, app, in.Route)
+ if !ok {
+ return
+ }
+ created, err := domains.AttachClaim(ctx, s.cfg.Releases, domains.Claim{
+ Host: host, Account: acct.Sqid, App: app, Route: route,
+ CreatedAt: time.Now().UTC().Format(time.RFC3339), CreatedBy: principal,
+ })
+ if errors.Is(err, domains.ErrHostUnavailable) {
+ jsonError(w, http.StatusConflict, "hostname unavailable")
+ return
+ } else if err != nil {
+ log.Printf("console: api domains attach %s/%s %s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not attach the hostname — try again")
+ return
+ }
+ s.audit(acct, app, principal, "domain-attach", "attached hostname "+host+" -> "+route+" (cli)")
+ s.bumpSerial(ctx)
+ writeJSON(w, http.StatusOK, map[string]any{"host": host, "route": route, "created": created})
+}
+```
+
+Helpers in the same file: `s.deniedDomains()` = `cfg.DenyDomains` + the origin's hostname (parse `s.cfg.Origin` once); `s.resolveAliasRoute` = explicit route → `provision.ValidHostShape` gate then accept (records are best-effort; operator routes have none — the box gate is authoritative, say so in the comment); empty route → `provision.ListRecords`, exactly one → its host, zero or many → 400 `"specify -route: the app has N instances"`. Detach: `NormalizeAliasHost` (400 on garbage — traversal defence), `DetachClaim`, audit + `bumpSerial` only when `removed`, always 200. List: `ListClaims` filtered to `acct.Sqid`/`app`, merge `status.LoadBoxes` aliases sections. Registrations in `registerCLIRoutes`:
+
+```go
+ s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/domains", s.handleAPIDomainList)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/domains", s.handleAPIDomainAttach)
+ s.mux.HandleFunc("DELETE /api/cli/apps/{account}/{app}/domains/{host}", s.handleAPIDomainDetach)
+```
+
+- [ ] **Step 4: Verify pass**, then `go test ./internal/console/ -count=1` (13s — run whole).
+- [ ] **Step 5: Commit** — `feat: console domains door — attach/detach/list hostnames (#151)`.
+
+---
+
+### Task 10: `carlos domains` CLI verb
+
+**Files:**
+- Modify: `apimode.go` — wire types + three `apiSession` methods (follow `instanceCreate` at `:1273-1296`)
+- Create: `domains_cli.go` (follow `instances_cli.go:237-259` shape: `flag.NewFlagSet`, `requireAPISession`, print)
+- Modify: `cli.go` — top-level dispatch `case "domains": runDomains(args[1:])` beside `case "instances"` (`:227` area is the run func; find the dispatch switch by grepping `runInstances(`), plus one usage line in the help text
+- Test: `domains_cli_test.go` (httptest console fake, the pattern of the existing `instances_cli` tests; env via `carlosTestEnv`, `cmd.Dir` in scratch)
+
+**Interfaces:**
+- Consumes: Task 9's wire shapes.
+- Produces:
+
+```go
+type apiDomainRecord struct {
+ Host, Route, Status, DNSStatus string // json: host, route, status, dns_status
+ CertExpiresAt int64 // json: cert_expires_at
+}
+func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route string) (created bool, resolvedRoute string, err error)
+func (s *apiSession) domainDetach(ctx context.Context, a apiAcct, app, host string) (removed bool, err error)
+func (s *apiSession) domainList(ctx context.Context, a apiAcct, app string) ([]apiDomainRecord, error)
+```
+
+- [ ] **Step 1: Failing tests** — fake console asserting method+path+body and returning canned JSON; verbs under test:
+ - `carlos domains attach -app tito -route slug.go.tito.io tickets.example.com` → POSTs the pair, prints `attached tickets.example.com -> slug.go.tito.io` (or `already attached` when created=false)
+ - `attach` with no positional host → usage error, exit 2
+ - `carlos domains detach -app tito tickets.example.com` → DELETE with the host path-escaped, prints `detached`/`was not attached`
+ - `carlos domains list -app tito` → table: HOST ROUTE STATUS DNS CERT-EXPIRES (cert as date or `-` when 0)
+- [ ] **Step 2: Verify failure.** `go test . -run Domains -count=1`
+- [ ] **Step 3: Implement.** `domainDetach` decodes the 200 body's `removed`. 409 from attach surfaces the server's `hostname unavailable` verbatim (`apiErrorFromBody`, `apimode.go:249`). `runDomains` dispatches attach/detach/list, default prints usage.
+- [ ] **Step 4: Verify pass** — `go test . -count=1` (root package is 97s; run it whole once here).
+- [ ] **Step 5: Commit** — `feat: carlos domains attach/detach/list (#151)`.
+
+---
+
+### Task 11: Full gate, docs, PR
+
+- [ ] **Step 1:** `gofmt -l` on every touched file (must print nothing); fix any.
+- [ ] **Step 2:** `go build ./... && go vet ./... && go test ./... -count=1` — all green; `GOOS=darwin go build ./...` compiles.
+- [ ] **Step 3:** README/CLAUDE.md: no changes needed (deploy reality unchanged until rollout); add one line to CLAUDE.md's Deploy reality section ONLY if asked.
+- [ ] **Step 4:** Push branch, open PR: title `route hostnames: attach/detach an extra public hostname on an existing route (#151)`, body = spec summary + `Closes #151` + the cross-session coordination note (unit-economics + HA + #82 merge agreements) + rollout order from spec §Rollout, ending with the Generated-with line from CLAUDE.md.
+- [ ] **Step 5:** Ping the #82 session with: package path `internal/domains`, exports `AttachClaim/DetachClaim/GetClaim/ListClaims/NormalizeAliasHost/HostDenied/ErrHostUnavailable/ClaimKey/ClaimsPrefix`, door file `internal/console/apidomains.go`, CLI file `domains_cli.go`, branch `worktree-route-hostnames`.
+
+## Self-review notes (done at write time)
+
+- Spec §1-§8 → Tasks 1-10 cover record/table/reconcile/edge/door/CLI/stamping/readings/acl/wiring; §Rollout → Task 11.
+- Type names cross-checked: `RouteAlias` (T2) consumed by T3/T4/T5/T6; `Claim` (T1) by T3/T9; `AliasReading` (T6) by T9's list merge.
+- Deliberately absent: `CONSOLE_DENY_DOMAINS` values themselves (deploy-time, per deployment: flagship `oncarlos.com`, tito `go.tito.io,platform.tito.io`) — recorded in the PR body's rollout section, not in code.