carlos / platform Public

plan: domain attach implementation plan; spec gate-order correction

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 0e8819883f24b23a738a63e4e5a6f37ea6ec75d1 parent fa6d189
2 files changed, +763 −3
  • docs/superpowers/plans/2026-08-12-domain-attach.md +757 −0
  • docs/superpowers/specs/2026-08-12-domain-attach-design.md +6 −3
diff --git a/docs/superpowers/plans/2026-08-12-domain-attach.md b/docs/superpowers/plans/2026-08-12-domain-attach.md
new file mode 100644
index 000000000..6d4644eab
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-12-domain-attach.md
@@ -0,0 +1,757 @@
+# Domain Attach (#82) 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:** Self-serve custom-domain attach: console Settings UI, a shared
+attach/detach core, and `carlos deploy -host` auto-attach, per
+`docs/superpowers/specs/2026-08-12-domain-attach-design.md`.
+
+**Architecture:** The door writes a global create-only claim
+(`domains/<host>.json`); an app with a ready route gets an alias claim
+(#151's box-side machinery serves it), a routeless static app gets a
+claim plus a declared `provision.Record` that #137's reconciler
+materializes. One console-side core implements both; the browser door and
+the CLI-API door are thin wrappers. #151 (branch `worktree-route-hostnames`)
+owns the claim writer, the exported validation spot, the `/api/cli/.../domains`
+handlers and the `carlos domains` verbs — Tasks 1–2 build interim,
+clearly-marked stand-ins so work proceeds tonight; Task 8 swaps them for
+#151's exports when their ping arrives.
+
+**Tech Stack:** Go, stdlib `net/http` mux patterns, `release.Store`
+(create-only CAS via `Put(ctx, key, body, "")` → `release.ErrCASConflict`),
+html/template console templates.
+
+## Global Constraints
+
+- Worktree: `/home/paulca/github.com/carlosframework/platform/.claude/worktrees/domain-attach`, branch `worktree-domain-attach` (stacked on `worktree-route-records`). Never touch the main checkout (a different session's dirty `unit-economics` state lives there).
+- Go commands need: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`, sandbox disabled ("read-only file system" is the tell).
+- Gate before every commit: `go build ./... && go vet ./... && go test ./... -count=1`, plus `gofmt -l` clean on touched files.
+- Wire shapes are additive; anything written before the change must still parse.
+- Any test that shells out to the carlos binary builds env via `carlosTestEnv(t, ...)` (`e2e_test.go:81`) AND sets `cmd.Dir` to a scratch dir.
+- Claim shape is FROZEN by cross-session agreement: `{schema, host, account, app, route, created_at, created_by}` — do not add fields.
+- Commit messages end with: `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
+
+---
+
+### Task 1: Interim claim package `internal/domainclaim`
+
+**Files:**
+- Create: `internal/domainclaim/domainclaim.go`
+- Test: `internal/domainclaim/domainclaim_test.go`
+
+**Interfaces:**
+- Produces: `Claim` struct, `Key(host string) string`,
+ `Create(ctx context.Context, s release.Store, c Claim) error`,
+ `Get(ctx context.Context, s release.Store, host string) (Claim, string, error)`,
+ `Delete(ctx context.Context, s release.Store, host string) error`,
+ `ListForApp(ctx context.Context, s release.Store, account, app string) ([]Claim, error)`.
+ Task 3 (core), Task 8 (swap) consume these.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+// internal/domainclaim/domainclaim_test.go
+package domainclaim
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "carlosframework/platform/internal/release" // match the module path used by internal/provision imports
+)
+
+func TestCreateIsCreateOnly(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ c := Claim{Schema: 1, Host: "www.example.com", Account: "bdj", App: "story", CreatedAt: time.Unix(1755000000, 0).UTC(), CreatedBy: "cli:paul@keymail.dev"}
+ if err := Create(ctx, s, c); err != nil {
+ t.Fatalf("first create: %v", err)
+ }
+ err := Create(ctx, s, Claim{Schema: 1, Host: "www.example.com", Account: "bab", App: "other", CreatedAt: time.Unix(1755000001, 0).UTC()})
+ if !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("second create: got %v, want ErrCASConflict", err)
+ }
+}
+
+func TestGetRoundTripsAndKeyShape(t *testing.T) {
+ if got := Key("www.example.com"); got != "domains/www.example.com.json" {
+ t.Fatalf("Key = %q", got)
+ }
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ in := Claim{Schema: 1, Host: "example.com", Account: "bdj", App: "story", Route: "story.bdj.oncarlos.com", CreatedAt: time.Unix(1755000000, 0).UTC()}
+ if err := Create(ctx, s, in); err != nil {
+ t.Fatal(err)
+ }
+ out, etag, err := Get(ctx, s, "example.com")
+ if err != nil || etag == "" {
+ t.Fatalf("Get: %v etag=%q", err, etag)
+ }
+ if out != in {
+ t.Fatalf("round trip: got %+v want %+v", out, in)
+ }
+}
+
+func TestListForAppFiltersOtherOwners(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ for _, c := range []Claim{
+ {Schema: 1, Host: "a.example.com", Account: "bdj", App: "story", CreatedAt: time.Unix(1755000000, 0).UTC()},
+ {Schema: 1, Host: "b.example.com", Account: "bdj", App: "blog", CreatedAt: time.Unix(1755000000, 0).UTC()},
+ {Schema: 1, Host: "c.example.com", Account: "bab", App: "story", CreatedAt: time.Unix(1755000000, 0).UTC()},
+ } {
+ if err := Create(ctx, s, c); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := ListForApp(ctx, s, "bdj", "story")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 || got[0].Host != "a.example.com" {
+ t.Fatalf("ListForApp = %+v, want exactly a.example.com", got)
+ }
+}
+
+func TestDeleteIsIdempotent(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if err := Delete(ctx, s, "never-existed.example.com"); err != nil {
+ t.Fatalf("delete of absent claim: %v", err)
+ }
+}
+```
+
+Before writing: open `internal/provision/provision.go:1-30` and copy the
+exact module import path it uses for `internal/release`.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/domainclaim/ -count=1`
+Expected: FAIL — package does not exist / functions undefined.
+
+- [ ] **Step 3: Write the implementation**
+
+```go
+// internal/domainclaim/domainclaim.go
+// Package domainclaim reads and writes the global custom-domain claim at
+// domains/<host>.json — the create-only record whose successful create IS
+// the fleet-wide uniqueness check for an attached hostname (first writer
+// wins; a lost create surfaces release.ErrCASConflict and the door answers
+// "host unavailable" without naming the holder).
+//
+// INTERIM (#82/#151 cross-session agreement, 2026-08-12): #151's branch
+// (worktree-route-hostnames) owns the canonical claim writer; this package
+// is the agreed shape built here so #82's doors can proceed, and it is
+// REPLACED by #151's export when the branches merge. The shape below is
+// frozen by that agreement — do not add fields here.
+package domainclaim
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "carlosframework/platform/internal/release"
+)
+
+// Claim is the wire shape at domains/<host>.json. Route names the primary
+// route this host aliases; empty Route means the host is (or will be) its
+// own primary route — the first-route static case.
+type Claim struct {
+ Schema int `json:"schema"`
+ Host string `json:"host"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Route string `json:"route,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ CreatedBy string `json:"created_by,omitempty"`
+}
+
+const prefix = "domains/"
+
+// Key is the bucket key for a host's claim.
+func Key(host string) string { return prefix + host + ".json" }
+
+// Create writes the claim create-only: a lost race (any existing claim,
+// whoever holds it) is release.ErrCASConflict, never an overwrite.
+func Create(ctx context.Context, s release.Store, c Claim) error {
+ body, err := json.MarshalIndent(c, "", " ")
+ if err != nil {
+ return err
+ }
+ _, err = s.Put(ctx, Key(c.Host), body, "")
+ return err
+}
+
+// Get reads one claim and its etag.
+func Get(ctx context.Context, s release.Store, host string) (Claim, string, error) {
+ body, etag, err := s.Get(ctx, Key(host))
+ if err != nil {
+ return Claim{}, "", err
+ }
+ var c Claim
+ if err := json.Unmarshal(body, &c); err != nil {
+ return Claim{}, "", fmt.Errorf("claim %s: malformed: %w", Key(host), err)
+ }
+ return c, etag, nil
+}
+
+// Delete releases a claim. Store.Delete is idempotent, so releasing an
+// already-gone claim is not an error.
+func Delete(ctx context.Context, s release.Store, host string) error {
+ return s.Delete(ctx, Key(host))
+}
+
+// ListForApp returns the claims held by one account+app pair. Claims that
+// fail to parse are skipped: a malformed claim is #151's reconciler's
+// problem to surface, not a reason to blank an app's Settings list.
+func ListForApp(ctx context.Context, s release.Store, account, app string) ([]Claim, error) {
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ var out []Claim
+ for _, k := range keys {
+ if !strings.HasSuffix(k, ".json") {
+ continue
+ }
+ host := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json")
+ c, _, err := Get(ctx, s, host)
+ if err != nil {
+ continue
+ }
+ if c.Account == account && c.App == app {
+ out = append(out, c)
+ }
+ }
+ return out, nil
+}
+```
+
+If `s.Get` on a missing key returns a distinct sentinel (check
+`internal/release/store.go` — provision's callers test it), have `Get`
+pass it through unwrapped so callers can distinguish absent from
+malformed.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/domainclaim/ -count=1`
+Expected: PASS
+
+- [ ] **Step 5: Gate and commit**
+
+```bash
+go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l internal/domainclaim/
+git add internal/domainclaim/
+git commit -m "domainclaim: interim claim record package (agreed #151 shape)
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: Attach policy validation
+
+**Files:**
+- Create: `internal/console/domainpolicy.go`
+- Test: `internal/console/domainpolicy_test.go`
+
+**Interfaces:**
+- Consumes: `provision.ValidHostShape(host string) bool`.
+- Produces: `func attachHostRefusal(host, appsDomain string) string` —
+ empty string means allowed; non-empty is the member-facing refusal
+ text. Tasks 3–5 consume it. (Task 8 relocates the policy into #151's
+ exported validation spot; the tests move with it.)
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+// internal/console/domainpolicy_test.go
+package console
+
+import "testing"
+
+func TestAttachHostRefusal(t *testing.T) {
+ cases := []struct {
+ name, host, appsDomain string
+ wantAllowed bool
+ }{
+ {"plain custom fqdn", "www.example.com", "oncarlos.com", true},
+ {"apex", "example.com", "oncarlos.com", true},
+ {"uppercase rejected", "WWW.example.com", "oncarlos.com", false},
+ {"empty host", "", "oncarlos.com", false},
+ {"traversal shape", "../control/serial", "oncarlos.com", false},
+ {"apps domain itself", "oncarlos.com", "oncarlos.com", false},
+ {"under apps domain", "story.bdj.oncarlos.com", "oncarlos.com", false},
+ {"apps domain unset allows customs", "www.example.com", "", true},
+ {"suffix but not label boundary", "notoncarlos.com", "oncarlos.com", true},
+ }
+ for _, c := range cases {
+ got := attachHostRefusal(c.host, c.appsDomain)
+ if (got == "") != c.wantAllowed {
+ t.Errorf("%s: attachHostRefusal(%q,%q) = %q, want allowed=%v", c.name, c.host, c.appsDomain, got, c.wantAllowed)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/console/ -run TestAttachHostRefusal -count=1`
+Expected: FAIL — `attachHostRefusal` undefined.
+
+- [ ] **Step 3: Write the implementation**
+
+```go
+// internal/console/domainpolicy.go
+// Attach-door host policy (#82). #151 exports the shared validation spot
+// for the domains door; when the branches merge these checks land there
+// (Task 8) — the rules themselves are this file's contract:
+// - hostname shape only (ValidHostShape — apexes allowed, lowercase),
+// - never platform space: an attached domain is a foreign domain by
+// definition, so anything equal to or under the deployment's apps
+// domain is refused before any claim is written.
+// First-writer-wins is NOT here: the claim's create-only write is the
+// entire uniqueness check, and its refusal is the door's neutral
+// "host unavailable".
+package console
+
+import (
+ "strings"
+
+ "carlosframework/platform/internal/provision"
+)
+
+func attachHostRefusal(host, appsDomain string) string {
+ if !provision.ValidHostShape(host) {
+ return "not a valid hostname"
+ }
+ if appsDomain != "" {
+ if host == appsDomain || strings.HasSuffix(host, "."+appsDomain) {
+ return "hosts under the platform's apps domain are assigned by the platform, not attached"
+ }
+ }
+ return ""
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/console/ -run TestAttachHostRefusal -count=1`
+Expected: PASS
+
+- [ ] **Step 5: Gate and commit**
+
+```bash
+go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l internal/console/domainpolicy.go
+git add internal/console/domainpolicy.go internal/console/domainpolicy_test.go
+git commit -m "console: attach-door host policy — shape + never platform space
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: Attach/detach core
+
+**Files:**
+- Create: `internal/console/domainattach.go`
+- Test: `internal/console/domainattach_test.go`
+
+**Interfaces:**
+- Consumes: `domainclaim.Create/Get/Delete` (Task 1),
+ `attachHostRefusal` (Task 2), `provision.CreateRecord` /
+ `provision.DeleteRecord` / `provision.Record`
+ (`internal/provision/provision.go`), `release.ChannelsPrefix(acct, app)`
+ (`internal/release/release.go:207`), `registry.Open` + route listing
+ (`internal/registry`).
+- Produces (Tasks 4, 5, 8 consume):
+
+```go
+type attachResult struct {
+ Claim domainclaim.Claim
+ FirstRoute bool // true when a declared record was written
+ Channel string // channel the first-route record follows ("" for alias)
+}
+// attachDomain: kindFlag/channelFlag may be "" (meaning: infer).
+func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kindFlag, channelFlag, principal string) (attachResult, *doorRefusal)
+func (s *Server) detachDomain(ctx context.Context, acct *Account, app, host, principal string) *doorRefusal
+type doorRefusal struct {
+ Status int // http status the doors translate to
+ Msg string // member-facing text; NEVER names another account
+}
+```
+
+Behavior contract (from spec §3–§4), which the tests below pin:
+
+1. `attachHostRefusal` first → 422 refusal.
+2. Ready-route lookup: open `s.cfg.Registry` (skip lookup when the path
+ is empty — tests), list routes, find the app+account's first
+ non-Platform ready route by host order (the `firstReadyUpstream`
+ selection rule, `internal/domains/reconcile.go:617`, minus the
+ upstream-copy part — only the HOST is needed). Found → alias claim
+ `{Schema: 1, Host: host, Account: acct.Sqid, App: app, Route: <that host>, CreatedAt: now, CreatedBy: principal}`;
+ no record written; `FirstRoute: false`.
+3. No ready route → first-route path: `kindFlag` must be `""` or
+ `"static"` (anything else → 422 "an app with no running route can only
+ attach a static domain — deploy first, then attach"). Channel =
+ `channelFlag`, or the app's sole channel from
+ `s.cfg.Releases.List(ctx, release.ChannelsPrefix(acct.Sqid, app))`
+ (strip prefix + `.json` for names); zero channels → 422 "nothing
+ shipped yet — carlos ship first"; more than one and no flag → 422
+ naming them all. Then claim (Route empty) + `provision.CreateRecord`
+ with `Record{Schema: 1, Host: host, App: app, Account: acct.Sqid, Channel: channel, Kind: "static", CreatedAt: now, CreatedBy: principal}`.
+ If CreateRecord fails after the claim landed, best-effort
+ `domainclaim.Delete` then return 500.
+4. `domainclaim.Create` losing the race (`errors.Is(err, release.ErrCASConflict)`)
+ → 409 with exactly `"host unavailable"` — including when the caller's
+ own account holds it (no owner leak; GET is where you see your own).
+5. Success: `s.audit(acct, app, principal, "domain-attach", ...)` +
+ `s.bumpSerial(ctx)` inside the core (both doors get it for free);
+ detach mirrors with `"domain-detach"`.
+6. `detachDomain`: `domainclaim.Get`; absent, or `Account != acct.Sqid`,
+ or `App != app` → 404 refusal (byte-identical treatment comes from
+ the doors calling `http.NotFound`). Claim's `Route == ""` (first-route)
+ → `provision.DeleteRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)`
+ first, then `domainclaim.Delete`, audit, serial.
+
+- [ ] **Step 1: Write the failing tests**
+
+Write `internal/console/domainattach_test.go` in package `console`,
+following the existing server-test construction pattern — before writing,
+open `internal/console/apiinstances_test.go` (or the nearest test that
+builds a `*Server` with `cfg.Releases = release.NewDirStore(t.TempDir())`)
+and copy its server/fixture setup helper. Cover, minimum:
+
+```go
+// Names are the contract; bodies follow the fixture pattern you copied.
+func TestAttachAliasWhenAppHasReadyRoute(t *testing.T) // registry fixture with a ready route → Claim.Route = that host, no record under RecordsPrefix, FirstRoute false
+func TestAttachFirstRouteWritesClaimThenRecord(t *testing.T) // no routes, one shipped channel → claim with empty Route AND a parseable provision.Record at RecordKey(sqid, app, host) with Kind static + that channel
+func TestAttachAmbiguousChannelRefusesNamingAll(t *testing.T) // two channels, no channelFlag → 422, msg contains both names, NO claim written
+func TestAttachProxiedKindWithoutRouteRefused(t *testing.T) // kindFlag "instance", no routes → 422, no claim
+func TestAttachHeldHostIsNeutral409(t *testing.T) // pre-created claim by another account → 409, msg exactly "host unavailable"
+func TestAttachPlatformSpaceRefused(t *testing.T) // host under cfg.AppsDomain → 422, no claim
+func TestDetachFirstRouteDeletesRecordThenClaim(t *testing.T)
+func TestDetachForeignClaimIs404(t *testing.T) // claim held by another account+app → 404 refusal
+```
+
+For the registry fixture: `reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))`
+then `reg.Upsert(registry.Route{Host: "story.bdj.oncarlos.com", Kind: "static", App: "story", Account: "bdj", Channel: "canary/live", Status: "ready"})`
+and point `cfg.Registry` at that path (match field names against the
+`Config` struct at `internal/console/server.go:165`).
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/console/ -run 'TestAttach|TestDetach' -count=1`
+Expected: FAIL — `attachDomain`/`detachDomain` undefined.
+
+- [ ] **Step 3: Implement `domainattach.go` to the behavior contract above**
+
+File header comment: name the spec
+(`docs/superpowers/specs/2026-08-12-domain-attach-design.md`) and the
+claim-then-record write order as the invariant (uniqueness is decided
+before anything else exists; a half-failed attach leaves at worst an
+unclaimed record-less claim that detach or retry clears).
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/console/ -run 'TestAttach|TestDetach' -count=1`
+Expected: PASS
+
+- [ ] **Step 5: Gate and commit**
+
+```bash
+go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l internal/console/domainattach.go
+git add internal/console/domainattach.go internal/console/domainattach_test.go
+git commit -m "console: attach/detach core — alias claim or claim+declared record
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: Browser door — Settings attach/detach handlers
+
+**Files:**
+- Create: `internal/console/appdomains.go`
+- Modify: `internal/console/server.go` (route registration block, near the
+ `POST /a/{sqid}/apps/{app}/rename` registration at `server.go:378-403`)
+- Test: `internal/console/appdomains_test.go`
+
+**Interfaces:**
+- Consumes: `s.accountApp(w, r)` (`server.go:1533` — session + membership
+ + app 404), the CSRF constant-time compare pattern and gate ORDER from
+ `handleAppDelete` (`internal/console/appsettings.go:339`),
+ `attachDomain`/`detachDomain` (Task 3).
+- Produces: `POST /a/{sqid}/apps/{app}/domains/attach` (form fields
+ `csrf`, `host`, optional `channel`) and
+ `POST /a/{sqid}/apps/{app}/domains/detach` (form fields `csrf`, `host`,
+ `confirm` — must equal the host). Redirect back to
+ `/a/{sqid}/apps/{app}/settings` on success; `doorRefusal` renders via
+ `s.renderMessage` with the refusal's status.
+
+Gate order (copy `handleAppDelete`'s shape exactly, WITHOUT the
+`requireFreshAuth` step — spec §6: attach/detach are member-reversible,
+no passkey step-up; detach's protection is the typed-host confirm):
+session (`accountApp`) → CSRF (`subtle.ConstantTimeCompare`) → role must
+be `RoleOwner` else byte-identical `http.NotFound` → refusals → act.
+
+- [ ] **Step 1: Write failing handler tests** — follow the request/response
+test pattern of the nearest existing browser-door test (grep
+`internal/console/*_test.go` for `handleAppDelete` or `csrf` to find it;
+copy its session-fixture helper). Cover: happy attach (alias fixture from
+Task 3's test), CSRF mismatch → 403, non-owner role → 404 with a body
+byte-identical to an unknown app's 404, detach without matching `confirm`
+→ 422 and claim survives, detach happy path → redirect + claim gone.
+
+- [ ] **Step 2: Run to verify they fail** — `go test ./internal/console/ -run TestAppDomains -count=1` → FAIL, handlers undefined.
+
+- [ ] **Step 3: Implement handlers + register routes**
+
+```go
+// server.go, in the block with the other app POST routes:
+s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/domains/attach", s.handleAppDomainAttach)
+s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/domains/detach", s.handleAppDomainDetach)
+```
+
+Handlers live in `appdomains.go`, thin over the Task 3 core; translate
+`doorRefusal{Status, Msg}` via `s.renderMessage(w, r, refusal.Status, "Refused", refusal.Msg)`.
+
+- [ ] **Step 4: Run to verify they pass** — same command, PASS.
+
+- [ ] **Step 5: Gate and commit**
+
+```bash
+go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l internal/console/appdomains.go internal/console/server.go
+git add internal/console/appdomains.go internal/console/appdomains_test.go internal/console/server.go
+git commit -m "console: browser attach/detach door on app Settings
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 5: Settings page UI — replace the honesty text
+
+**Files:**
+- Modify: `internal/console/templates/app_settings.html:49-57` (the
+ "Add a custom domain … carlos add … on the platform's roadmap" block)
+- Modify: `internal/console/appsettings.go` (`assembleSettings` — add
+ attached-domain rows) and/or `internal/console/view.go` if the data
+ fits `DomainView` (`view.go:71`) better; implementer picks whichever
+ keeps the template loop single.
+- Modify: `internal/console/appsettings.go:15-18` package comment — the
+ "domains attach on the box today" sentence is now false; rewrite to
+ name this door.
+- Test: extend `internal/console/appsettings_test.go` (or the file
+ Task 4's fixture lives in).
+
+**Interfaces:**
+- Consumes: `domainclaim.ListForApp` (Task 1), `DomainView` +
+ `domainView(rt, edgeIPs)` (`view.go:71,79`), `s.cfg.EdgeIPs`,
+ the Task 4 POST endpoints.
+
+Behavior:
+- The existing Domains & TLS table (`app_settings.html:30-48`) keeps
+ showing ROUTED hosts. Add claim-only rows for attached hosts that have
+ no colocated registry row yet: Type `custom · attaching`, DNS column
+ `""` (unknown), Guidance = the same A-record text `domainView` builds
+ from `edgeIPs`.
+- Below the table: the attach form (text input `host`, optional
+ `channel` input shown only when the app has no route, hidden `csrf`,
+ submit "Attach domain") posting to `{{.Base}}/apps/{{.Detail.App}}/domains/attach`;
+ per attached host a detach form with typed-host `confirm` input.
+ Follow the rename form (`app_settings.html:15-21`) for markup/CSRF
+ idiom; icons are vendored Lucide constants if any are used — no CDN.
+- Empty state line: "This app already answers at its platform domain;
+ attaching adds addresses, it never removes one."
+
+- [ ] **Step 1: Failing test** — assert the settings page render (a) no
+longer contains the string `carlos add -host`, (b) contains
+`domains/attach`, and (c) with a claim fixture present, contains that
+host. Use the page-render test pattern already covering
+`app_settings` (grep for `app_settings` in `internal/console/*_test.go`).
+- [ ] **Step 2: Run to verify it fails** — render still shows the old text.
+- [ ] **Step 3: Implement template + assembly changes.**
+- [ ] **Step 4: Run to verify it passes** — plus eyeball
+`go test ./internal/console/ -count=1` for template-parse regressions.
+- [ ] **Step 5: Gate and commit**
+
+```bash
+go build ./... && go vet ./... && go test ./... -count=1
+git add internal/console/templates/app_settings.html internal/console/appsettings.go internal/console/view.go internal/console/appsettings_test.go
+git commit -m "console: Settings attaches domains itself — honesty text retired
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: CLI client + `carlos deploy -host` auto-attach
+
+**Files:**
+- Modify: `apimode.go` (new apiSession methods), `deploy.go`
+ (auto-attach step + honest DNS-failure text)
+- Test: `deploy_test.go` (or the file that already tests
+ `deployTargets`/`watchVersion` — find with `grep -l deployTargets *_test.go`)
+
+**Interfaces:**
+- Consumes: `apiSession.getJSON/postJSON` (`apimode.go:266,291`),
+ `apiPath(a, app, tail)` (`apimode.go:217`), `deployTargets`
+ (`deploy.go:180`), `staticDeployHosts` (`deploy.go:223`),
+ `watchVersion` (`deploy.go:417`).
+- Produces (Task 8's e2e consumes; #151's verbs will share):
+
+```go
+type apiDomain struct {
+ Host string `json:"host"`
+ Route string `json:"route,omitempty"`
+ DNS string `json:"dns,omitempty"` // "pointing" | "elsewhere" | ""
+ Guidance string `json:"guidance,omitempty"`
+}
+func (s *apiSession) domainList(ctx context.Context, a apiAcct, app string) ([]apiDomain, error) // GET .../domains
+func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, kind, channel string) (apiDomain, error) // POST .../domains/{host}
+```
+
+Wire contract (agreed with #151; the server half arrives in Task 8):
+`POST /api/cli/apps/{account}/{app}/domains/{host}` body
+`{"kind":"static","channel":"canary/live"}` (both optional) → 201 with an
+`apiDomain`; 409 body contains `host unavailable`; 422 refusal text
+passes through to the user verbatim.
+
+Behavior in `runDeploy`: when `-host` is set and the resolved static
+target host is NOT in `sess.instanceList` results and not already
+claimed (a `domainList` miss), call `domainAttach` (kind from `-kind`
+flag when it is `static`, channel from the resolved deploy channel)
+BEFORE shipping; print `attached <host> (first route: static, channel <c>)`
+or the refusal and stop. After `watchVersion` timeout, if the last
+failure was connection/DNS-class, fetch `domainList` and print the
+guidance + stamp with exit text:
+`DNS for <host> is not pointing at this deployment yet. Create: <guidance>. Watch with 'carlos domains -app <app>'; re-run this deploy any time — the shipped release is already promoted.`
+(exit nonzero; never claim success).
+
+- [ ] **Step 1: Failing tests** — an `httptest.NewServer` fake console
+(follow the existing fake-console pattern; find it with
+`grep -rl "httptest.NewServer" *_test.go | head`) asserting: (a)
+`domainAttach` hits the right path+body, (b) deploy with `-host` on an
+unclaimed static target POSTs attach before ship, (c) the
+DNS-not-pointing exit message includes the fake's guidance text and the
+process exits nonzero.
+- [ ] **Step 2: Run to verify they fail.**
+- [ ] **Step 3: Implement client methods + deploy wiring.**
+- [ ] **Step 4: Run to verify they pass** — `go test . -run 'Deploy|Domain' -count=1`.
+- [ ] **Step 5: Gate and commit**
+
+```bash
+go build ./... && go vet ./... && go test ./... -count=1
+git add apimode.go deploy.go deploy_test.go
+git commit -m "cli: deploy -host auto-attaches an unclaimed custom domain
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 7: Backfill golden — the story.carlosframework.com shape adopts
+
+**Files:**
+- Modify: `internal/provision/reconcile_test.go` (one new test beside the
+ existing adopt-on-match tests — find them with
+ `grep -n "adopt" internal/provision/reconcile_test.go`)
+
+**Interfaces:**
+- Consumes: `Reconcile` + `ReconcileConfig` (`reconcile.go:29,57`),
+ `shapeMismatch` semantics (`reconcile.go:143`), `CreateRecord`.
+
+- [ ] **Step 1: Write the test** (it should PASS already — #137 built
+adoption; this golden pins the #82 backfill path specifically):
+pre-Upsert the operator-added shape
+`registry.Route{Host: "story.carlosframework.com", Kind: "static", App: "story", Account: "bdj", Channel: "canary/live", Status: "ready"}`,
+write the matching declared record
+(`Record{Schema: 1, Host: "story.carlosframework.com", App: "story", Account: "bdj", Channel: "canary/live", Kind: "static"}`),
+run `Reconcile`, assert the route is now `Provisioned` and its
+Socket/DBPath are still empty. Add a companion negative: same record but
+existing route `Kind: "instance"` → NOT adopted, still unprovisioned.
+- [ ] **Step 2: Run** — `go test ./internal/provision/ -run Adopt -count=1`
+(adjust `-run` to the names used). Expected: PASS. If the static shape
+does NOT adopt, that is a real #137 gap — STOP and report rather than
+patching around it.
+- [ ] **Step 3: Gate and commit**
+
+```bash
+go build ./... && go vet ./... && go test ./... -count=1
+git add internal/provision/reconcile_test.go
+git commit -m "provision: golden — declared static record adopts the story-shaped route
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 8: Integration with #151 (GATED — wait for their ping)
+
+**Files:** determined by #151's ping (their package path, exported names,
+door files, CLI verb file). Expected shape of the work:
+- Merge `origin/worktree-route-hostnames` into `worktree-domain-attach`.
+- Delete `internal/domainclaim/`; repoint Tasks 1–6's imports at #151's
+ exported claim writer/reader (same frozen shape — mechanical rename).
+- Move `attachHostRefusal`'s checks + tests into #151's exported
+ validation spot; delete `domainpolicy.go`.
+- Wire `attachDomain`'s first-route branch into #151's
+ `POST /api/cli/.../domains/{host}` handler (their handler gates via the
+ `apiInstancePrincipal` pattern; the core call slots where their
+ alias-claim write happens), and `detachDomain` likewise; confirm their
+ GET's response fields cover `apiDomain` (Task 6) — if their names
+ differ, Task 6's client adapts to THEIRS (wire contract is theirs to
+ pin).
+- e2e (new file `domains_e2e_test.go`, `carlosTestEnv` + `cmd.Dir`
+ scratch): `carlos deploy -kind static -host <h>` against a real test
+ console — attach happens, record exists, second attach from another
+ account refuses neutrally; `carlos domains -app` lists it.
+
+**Steps:**
+- [ ] Merge their branch; resolve; full gate.
+- [ ] Swap stub → their exports (compile-driven: delete the stub, chase errors).
+- [ ] Wire door branches; run THEIR door tests + Task 3–6 tests together.
+- [ ] Write + run the e2e.
+- [ ] Gate and commit:
+
+```bash
+go build ./... && go vet ./... && go test ./... -count=1
+git add -A
+git commit -m "domains: integrate #151 door — one claim writer, one validation spot
+
+Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
+```
+
+If the ping has not arrived when Tasks 1–7 are done: hold this task,
+proceed to Task 9's PR as DRAFT noting the gate, and check for the ping
+periodically — do not re-implement #151's door.
+
+---
+
+### Task 9: Whole-branch review, push, PR
+
+- [ ] **Step 1: Whole-branch review** — reread the spec top to bottom
+against `git diff worktree-route-records...HEAD`; walk every §2 ruling
+and §8 test bullet to a test that exists. Fix inline.
+- [ ] **Step 2: Full gate one more time** (all packages, `-count=1`), plus
+`GOOS=darwin GOARCH=arm64 go build ./...` (the CI cross-compile).
+- [ ] **Step 3: Push and open the PR**
+
+```bash
+git push -u origin worktree-domain-attach
+gh pr create --repo carlosframework/platform --base main \
+ --title "Domain attach: self-serve custom domains from Settings, CLI and deploy (#82)" \
+ --body "<summary; note stacked-on-#137 + #151 integration state; closes #82>
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)"
+```
+
+Draft if Task 8 is still gated. Merging is Paul's action — never offer
+or take it.
diff --git a/docs/superpowers/specs/2026-08-12-domain-attach-design.md b/docs/superpowers/specs/2026-08-12-domain-attach-design.md
index f9f75abf4..90c751c54 100644
--- a/docs/superpowers/specs/2026-08-12-domain-attach-design.md
+++ b/docs/superpowers/specs/2026-08-12-domain-attach-design.md
@@ -134,9 +134,12 @@ 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).
+Gating follows the CLI-API family's pattern, not the browser doors':
+bearer token (app-token tried first, exact sqid+app binding), byte-
+identical 404 for every non-answer, rate limit on the token hash, audit
+entries, serial bump after every write. The browser Settings door (§6)
+carries the session → CSRF → owner-404 ordering instead — both doors call
+the same attach/detach core.
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