carlos / platform Public

Merge pull request #169 from carlosframework/worktree-woodstar-carloku

Woodstar → Carloku migration: spec + Plans A/B (docs)
Paul Campbell e03d95b1a0bc81643d4a92b3280c1fc021260304 parent b56213fparent 49d8890
3 files changed, +1602 −0
  • docs/superpowers/plans/2026-08-12-delegated-wildcard-certs.md +427 −0
  • docs/superpowers/plans/2026-08-12-woodstar-w0-relay-fanout.md +751 −0
  • docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md +424 −0
diff --git a/docs/superpowers/plans/2026-08-12-delegated-wildcard-certs.md b/docs/superpowers/plans/2026-08-12-delegated-wildcard-certs.md
new file mode 100644
index 000000000..55f6fe6c3
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-12-delegated-wildcard-certs.md
@@ -0,0 +1,427 @@
+# Delegated-Challenge Wildcard Certs 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:** the edge can obtain `*.<customer-domain>` certs by publishing
+ACME DNS-01 challenge TXTs **in the platform's own zone**, at a
+delegation target the customer has CNAMEd to — no customer DNS
+credentials, no knowledge of their provider.
+
+**Architecture:** `wildcardManager` gains a delegated-parent source (a
+closure, like its existing `hosts` closure) and a delegation zone; the
+only issuance-path change is *where* the challenge TXT is published
+(`challengeFQDN()` mapping). The `dnsProvider` seam, obtain flow,
+backoff, cache, and fall-back-to-per-host behavior are untouched.
+The claim-derived parent source (`wildcard: true` on
+`internal/domains.Claim`) is **Plan C** (gated on #151 landing); this
+plan ships the mechanics inert-until-sourced.
+
+**Tech Stack:** Go, `internal/edge` (platform repo), existing
+`wildcard.go`/`dns.go` seams, `golang.org/x/crypto/acme`.
+
+**Spec:** `docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md`
+§4 (this branch).
+
+## Global Constraints
+
+- Platform repo gate before any commit lands: `go build ./...`,
+ `go vet ./...`, `go test ./... -count=1` all clean, every package;
+ `gofmt -l` clean on touched files; `GOOS=darwin go build ./...`
+ compiles (CI runs it).
+- Sandbox: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`;
+ disable the sandbox if the toolchain reports "read-only file system".
+- Every change lands through a PR — no direct commits to `main`.
+- Delegation-target convention (fixed here, per spec §10):
+ `_acme-challenge.<parent>.<delegation-zone>`, with the deployment's
+ delegation zone in env `CARLOS_ACME_DELEGATION_ZONE` (flagship value:
+ `acme.oncarlos.com` — a zone the existing DNSimple token already
+ writes; no new credentials).
+- Every failure degrades to per-host issuance — never a dead edge
+ (wildcard.go's standing contract; nothing here may violate it).
+- Work in a fresh worktree off `main`:
+ `git worktree add .claude/worktrees/delegated-wildcards -b edge-delegated-wildcards main`
+ (run from the repo root `/home/paulca/github.com/carlosframework/platform`).
+ All paths below are relative to that worktree.
+
+---
+
+### Task 1: Delegated parents join the parent set; challenges publish at the delegation target
+
+**Files:**
+- Modify: `internal/edge/wildcard.go` (struct fields, constructor,
+ `parentsFor` call site in `refresh()`, `solveDNS01`, new
+ `challengeFQDN`/`isDelegated` methods)
+- Test: `internal/edge/wildcard_test.go`
+
+**Interfaces:**
+- Consumes: existing `wildcardManager`, `parentsFor`, `solveDNS01`,
+ `fakeDNS` test double (wildcard_test.go:231).
+- Produces: `newWildcardManager(static []string, appsDomain string,
+ hosts func() []string, delegated func() []string, delegationZone
+ string, cache autocert.Cache, dns dnsProvider) *wildcardManager` —
+ two NEW trailing params before cache: `delegated` (nil = none) and
+ `delegationZone`. Also `(*wildcardManager).challengeFQDN(parent
+ string) string`, which Task 2's env plumbing and Plan C's derivation
+ rely on.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/edge/wildcard_test.go`:
+
+```go
+func TestDelegatedParentsJoinTheSet(t *testing.T) {
+ w := newWildcardManager(nil, "oncarlos.com", func() []string {
+ return []string{"app.bab.oncarlos.com"}
+ }, func() []string {
+ return []string{"woodstar.app"}
+ }, "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ w.mu.Lock()
+ parents := append([]string(nil), w.parents...)
+ w.mu.Unlock()
+ want := []string{"bab.oncarlos.com", "woodstar.app"}
+ if !reflect.DeepEqual(parents, want) {
+ t.Fatalf("parents = %v, want %v", parents, want)
+ }
+}
+
+func TestChallengeFQDNDelegation(t *testing.T) {
+ w := newWildcardManager(nil, "oncarlos.com", func() []string { return nil },
+ func() []string { return []string{"woodstar.app"} },
+ "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ // A delegated parent's TXT publishes into OUR zone, at the target
+ // the customer's CNAME points to.
+ if got, want := w.challengeFQDN("woodstar.app"), "_acme-challenge.woodstar.app.acme.oncarlos.com"; got != want {
+ t.Errorf("delegated: %q, want %q", got, want)
+ }
+ // A platform-zone parent publishes directly, exactly as before.
+ if got, want := w.challengeFQDN("bab.oncarlos.com"), "_acme-challenge.bab.oncarlos.com"; got != want {
+ t.Errorf("own-zone: %q, want %q", got, want)
+ }
+}
+
+func TestChallengeFQDNWithoutZoneNeverDelegates(t *testing.T) {
+ // No delegation zone configured: even a listed delegated parent
+ // publishes directly (misconfiguration degrades to the historical
+ // behavior, which per-host issuance then covers — never a dead edge).
+ w := newWildcardManager(nil, "", func() []string { return nil },
+ func() []string { return []string{"woodstar.app"} },
+ "", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ if got, want := w.challengeFQDN("woodstar.app"), "_acme-challenge.woodstar.app"; got != want {
+ t.Errorf("%q, want %q", got, want)
+ }
+}
+
+func TestNilDelegatedSourceIsFine(t *testing.T) {
+ // Every existing construction site passes nil — the zero-source
+ // manager must behave exactly as before this feature existed.
+ w := newWildcardManager([]string{"go.tito.io"}, "", func() []string { return nil },
+ nil, "acme.oncarlos.com", autocert.DirCache(t.TempDir()), &fakeDNS{})
+ w.refresh()
+ w.mu.Lock()
+ parents := append([]string(nil), w.parents...)
+ w.mu.Unlock()
+ if !reflect.DeepEqual(parents, []string{"go.tito.io"}) {
+ t.Fatalf("parents = %v, want [go.tito.io]", parents)
+ }
+ if got, want := w.challengeFQDN("go.tito.io"), "_acme-challenge.go.tito.io"; got != want {
+ t.Errorf("%q, want %q", got, want)
+ }
+}
+```
+
+Add `"reflect"` to the test file's imports if not already present.
+
+- [ ] **Step 2: Run to verify they fail**
+
+```bash
+cd /home/paulca/github.com/carlosframework/platform/.claude/worktrees/delegated-wildcards
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/edge/ -run 'TestDelegated|TestChallengeFQDN|TestNilDelegated' -count=1
+```
+
+Expected: FAIL — `newWildcardManager` has the old arity;
+`challengeFQDN` undefined. (Compilation failure across
+wildcard_test.go's existing call sites is part of the expected output.)
+
+- [ ] **Step 3: Implement**
+
+In `internal/edge/wildcard.go`:
+
+1. Add fields to `wildcardManager` (after `hosts`):
+
+```go
+ // delegated lists customer domains approved for delegated-challenge
+ // wildcard issuance (spec §4: claim-derived; nil = none). Their
+ // challenge TXTs publish into delegationZone — our own zone — at
+ // _acme-challenge.<parent>.<delegationZone>, which the customer has
+ // CNAMEd _acme-challenge.<parent> to. The platform never touches,
+ // or knows, the customer's DNS provider.
+ delegated func() []string
+ delegationZone string
+
+ // delegatedSet is refresh()'s snapshot of delegated(), read by
+ // challengeFQDN under mu (issuance runs on run()'s goroutine).
+ delegatedSet map[string]bool
+```
+
+2. Extend the constructor (two new params before `cache`):
+
+```go
+func newWildcardManager(staticDomains []string, appsDomain string, hosts func() []string, delegated func() []string, delegationZone string, cache autocert.Cache, dns dnsProvider) *wildcardManager {
+ return &wildcardManager{
+ cache: cache,
+ dns: dns,
+ directory: envOr("CARLOS_ACME_DIRECTORY", letsEncryptDirectory),
+ staticDomains: staticDomains,
+ appsDomain: strings.ToLower(strings.TrimSpace(appsDomain)),
+ hosts: hosts,
+ delegated: delegated,
+ delegationZone: strings.ToLower(strings.TrimSpace(delegationZone)),
+ certs: map[string]*tls.Certificate{},
+ nextAttempt: map[string]time.Time{},
+ delegatedSet: map[string]bool{},
+ wake: make(chan struct{}, 1),
+ }
+}
+```
+
+3. In `refresh()`, snapshot the delegated set and include it in the
+parent union. Replace the first two lines of `refresh()`:
+
+```go
+func (w *wildcardManager) refresh() {
+ var delegated []string
+ if w.delegated != nil {
+ for _, d := range w.delegated() {
+ if d = strings.ToLower(strings.TrimSpace(d)); d != "" {
+ delegated = append(delegated, d)
+ }
+ }
+ }
+ parents := parentsFor(append(append([]string(nil), w.staticDomains...), delegated...), w.appsDomain, w.hosts())
+ now := time.Now()
+ w.mu.Lock()
+ w.parents = parents
+ set := make(map[string]bool, len(delegated))
+ for _, d := range delegated {
+ set[d] = true
+ }
+ w.delegatedSet = set
+```
+
+(the rest of `refresh()` — the `need` loop, unlock, wake — is
+unchanged; `parentsFor` already dedupes and sorts, so a delegated
+domain that is also a static domain appears once).
+
+4. Add the mapping methods (near `wildcardPEMKey`):
+
+```go
+// challengeFQDN returns where parent's DNS-01 TXT is published: the
+// delegation target inside our own zone for delegated parents, or
+// directly at _acme-challenge.<parent> otherwise. With no delegation
+// zone configured the direct form is used for everyone — the
+// misconfigured state degrades to the historical behavior and per-host
+// issuance keeps covering (obtain will fail on the CA's lookup, log,
+// and back off — never a dead edge).
+func (w *wildcardManager) challengeFQDN(parent string) string {
+ if w.delegationZone != "" && w.isDelegated(parent) {
+ return "_acme-challenge." + parent + "." + w.delegationZone
+ }
+ return "_acme-challenge." + parent
+}
+
+func (w *wildcardManager) isDelegated(parent string) bool {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.delegatedSet[parent]
+}
+```
+
+5. In `solveDNS01`, replace the one line
+`challengeFQDN := "_acme-challenge." + parent` with:
+
+```go
+ challengeFQDN := w.challengeFQDN(parent)
+```
+
+(`present`, cleanup, and `waitForTXT(ctx, challengeFQDN, …)` then all
+operate on the delegation target for delegated parents — the TXT we
+wait for is the one we wrote, in our own zone, which also sidesteps
+negative-caching of the customer-side name. The CA independently
+follows the customer's CNAME.)
+
+6. Update `newWildcardFromEnv`'s construction call (Task 2 refines it;
+for this task pass the inert values so the package compiles):
+
+```go
+ return newWildcardManager(static, appsDomain, e.currentHosts, nil, os.Getenv("CARLOS_ACME_DELEGATION_ZONE"), cache, dns)
+```
+
+7. Mechanically update every existing `newWildcardManager(` call in
+`internal/edge/wildcard_test.go` (six sites listed at lines 251, 256,
+277, 288, 324, and inside `TestOnReloadHookFires`; grep for the exact
+set) to pass `nil, ""` for the two new params, e.g.:
+
+```go
+ w1 := newWildcardManager(nil, "oncarlos.com", func() []string { return nil }, nil, "", cache, &fakeDNS{})
+```
+
+- [ ] **Step 4: Run the full edge package**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/edge/ -count=1
+```
+
+Expected: PASS — the four new tests and every pre-existing wildcard
+test (their behavior is pinned by `TestNilDelegatedSourceIsFine`'s
+contract).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/edge/wildcard.go internal/edge/wildcard_test.go
+git commit -m "edge: delegated-challenge wildcard parents — publish DNS-01 TXTs at a delegation target in our own zone"
+```
+
+---
+
+### Task 2: Env plumbing + provider-miss logging
+
+**Files:**
+- Modify: `internal/edge/wildcard.go` (`newWildcardFromEnv`)
+- Test: `internal/edge/wildcard_test.go`
+
+**Interfaces:**
+- Consumes: Task 1's constructor.
+- Produces: `CARLOS_ACME_DELEGATION_ZONE` as the deployment config
+ surface (flagship: `acme.oncarlos.com`). Plan C replaces the `nil`
+ delegated source with the claim-derived one; nothing else changes.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `internal/edge/wildcard_test.go` (match the env-isolation
+style of the existing `TestNewWildcardFromEnv` at line 358 — it uses
+`t.Setenv`):
+
+```go
+func TestNewWildcardFromEnvDelegationZone(t *testing.T) {
+ t.Setenv("CARLOS_WILDCARD_DOMAINS", "go.tito.io")
+ t.Setenv("CARLOS_DNS_PROVIDER", "dnsimple")
+ t.Setenv("CARLOS_DNSIMPLE_TOKEN", "test-token")
+ t.Setenv("CARLOS_ACME_DELEGATION_ZONE", " Acme.OnCarlos.Com ")
+ e := &Edge{}
+ w := newWildcardFromEnv(e, autocert.DirCache(t.TempDir()))
+ if w == nil {
+ t.Fatal("manager should be on")
+ }
+ if w.delegationZone != "acme.oncarlos.com" {
+ t.Errorf("delegationZone = %q, want lowercased+trimmed acme.oncarlos.com", w.delegationZone)
+ }
+}
+```
+
+(If `Edge{}` needs more construction in the existing
+`TestNewWildcardFromEnv`, mirror exactly what that test does — it is
+the pattern-setter for this seam.)
+
+- [ ] **Step 2: Run to verify it fails**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/edge/ -run TestNewWildcardFromEnvDelegationZone -count=1
+```
+
+Expected: FAIL — `w.delegationZone` is `" Acme.OnCarlos.Com "` (raw
+env, not normalized) — Task 1's step 6 passed the env through without
+normalization; the constructor lowercases/trims, so if this PASSES
+already, note it and move on (the constructor normalizes). Either
+result is acceptable evidence; the test stays as the lock.
+
+- [ ] **Step 3: Confirm `newWildcardFromEnv` final form**
+
+```go
+func newWildcardFromEnv(e *Edge, cache autocert.Cache) *wildcardManager {
+ static := splitDomains(os.Getenv("CARLOS_WILDCARD_DOMAINS"))
+ appsDomain := strings.ToLower(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN")))
+ if len(static) == 0 && appsDomain == "" {
+ return nil
+ }
+ dns, reason := dnsProviderFromEnv()
+ if dns == nil {
+ log.Printf("edge: wildcard certs off (%s) — per-host issuance continues", reason)
+ return nil
+ }
+ // Delegated parents (spec §4): the SOURCE is claim-derived and lands
+ // with the claim-derivation pass (Plan C); until then the zone is
+ // configured but the set is empty — inert by construction.
+ return newWildcardManager(static, appsDomain, e.currentHosts, nil, os.Getenv("CARLOS_ACME_DELEGATION_ZONE"), cache, dns)
+}
+```
+
+- [ ] **Step 4: Run the whole gate**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off GOOS=darwin go build ./... && \
+gofmt -l internal/edge
+```
+
+Expected: all clean; gofmt prints nothing.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/edge/wildcard.go internal/edge/wildcard_test.go
+git commit -m "edge: CARLOS_ACME_DELEGATION_ZONE names the delegation zone; claim-derived source arrives with Plan C"
+```
+
+---
+
+### Task 3: PR
+
+- [ ] **Step 1: Push and open the PR**
+
+```bash
+git push -u origin edge-delegated-wildcards
+gh pr create --title "edge: delegated-challenge wildcard certs (customer domains, zero customer DNS knowledge)" --body "$(cat <<'EOF'
+Wildcard DNS-01 for a customer domain without holding customer DNS
+credentials: the customer CNAMEs _acme-challenge.<domain> to
+_acme-challenge.<domain>.<CARLOS_ACME_DELEGATION_ZONE> (a zone our
+existing DNSimple token already writes), and the edge publishes the
+challenge TXT at that target in OUR zone. Let's Encrypt follows the
+CNAME. The dnsProvider seam, obtain flow, backoff and
+degrade-to-per-host contract are untouched; the only issuance change is
+where the TXT goes (challengeFQDN mapping).
+
+Ships inert: the delegated-parent SOURCE is claim-derived
+(wildcard:true on internal/domains.Claim, agreed with #151) and lands
+with the claim-derivation pass after #151 merges; until then the set is
+empty by construction.
+
+Spec: docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md §4.
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)
+EOF
+)"
+```
+
+## Self-review notes
+
+- Spec §4 coverage: delegation-target convention ✓ (fixed:
+ `_acme-challenge.<parent>.<zone>`, env `CARLOS_ACME_DELEGATION_ZONE`,
+ flagship `acme.oncarlos.com`), provider-blind ✓ (dnsProvider
+ untouched), degrade-to-per-host ✓ (no-zone case tested), claim-derived
+ source explicitly deferred to Plan C with the seam (`delegated func()
+ []string`) already shaped for it.
+- NOT in this plan (Plan C, gated on #151 landing): claims → delegated
+ set derivation, `catchall` alias derivation, the `up.alias`
+ parent-derivation exclusion, `delegation_status` stamps, attach-door
+ refusals. NOT in this plan (Plan D): everything operational.
+- Wire-shape check: no storage shapes change here; the cert cache keys
+ (`wildcard-<domain>.pem`) already accommodate any parent string.
diff --git a/docs/superpowers/plans/2026-08-12-woodstar-w0-relay-fanout.md b/docs/superpowers/plans/2026-08-12-woodstar-w0-relay-fanout.md
new file mode 100644
index 000000000..f7e111893
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-12-woodstar-w0-relay-fanout.md
@@ -0,0 +1,751 @@
+# Woodstar W0 — Relay Fan-out 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:** `cmd/relay` optionally fronts its five satellite services and
+reproduces prod Caddy's path fan-out and wildcard-host behavior, so one
+Host-routed CARLOS route can serve the whole Woodstar app.
+
+**Architecture:** a new `internal/relay/fanout.go` wraps the existing
+`Server.Handler()` with an outermost handler that (a) dispatches Caddy's
+path prefixes to per-service `httputil.ReverseProxy` instances and (b)
+when a public apex domain is configured, reproduces the
+`*.woodstar.app` host behavior (www→apex 301, `/.well-known/atproto-did`
+→ apbridge, other subdomain paths → 302 to the apex profile). All new
+behavior is opt-in via new flags; unset flags = byte-identical behavior.
+
+**Tech Stack:** Go (woodstar repo, pure Go, no CGO), `net/http/httputil`,
+`net/http/httptest` for tests.
+
+**Spec:** platform repo
+`docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md`
+§6 (branch `worktree-woodstar-carloku`).
+
+## Global Constraints
+
+- Work in the **woodstar repo** (`/home/paulca/github.com/woodstarapp/woodstar`)
+ — but NEVER build in the main checkout (its CLAUDE.md; multiple
+ sessions active). First action: `git worktree add`.
+- Unset flags = today's behavior **byte-identical**. Dev/devbox
+ unaffected (Caddy keeps the fan-out there).
+- The fan-out route set mirrors `infra/devbox-init.sh`'s Caddy block
+ (the truthful superset of `infra/prod-Caddyfile`): `/media*`→media,
+ `/api/*`→indexer, `/stream/*`→notify, `/unfurl*` + `/img*`→unfurl
+ (`/img` serves every link-card image; added by final-review fix),
+ `/.well-known/webfinger` + `/.well-known/host-meta` + `/users/*` +
+ `/inbox` + `/ap/*`→apbridge, everything else→relay.
+- Wildcard-host behavior mirrors `infra/prod-Caddyfile`'s
+ `*.woodstar.app` block byte-for-byte: `www` → **permanent** (301)
+ redirect to apex with URI preserved; `/.well-known/atproto-did` →
+ apbridge; anything else → **temporary** (302) redirect to
+ `https://<apex>/<leftmost-label>`.
+- Proxies preserve the inbound `Host` header (Caddy's default; the
+ apbridge resolves users from it).
+- Gate: `make test` green; `gofmt -l` clean on touched files.
+
+---
+
+### Task 1: Fan-out path dispatch
+
+**Files:**
+- Create: `internal/relay/fanout.go`
+- Test: `internal/relay/fanout_test.go`
+
+**Interfaces:**
+- Produces: `relay.FanoutConfig{Media, Indexer, Notify, Unfurl,
+ APBridge, Domain string}` and
+ `relay.Fanout(inner http.Handler, cfg FanoutConfig) (http.Handler, error)`.
+ Task 3 adds host-aware behavior inside the same handler; Task 4 wires
+ `cmd/relay` flags to it.
+
+- [ ] **Step 1: Create the worktree**
+
+```bash
+cd /home/paulca/github.com/woodstarapp/woodstar
+git worktree add .worktrees/w0-fanout -b w0-relay-fanout origin/main
+cd .worktrees/w0-fanout
+```
+
+- [ ] **Step 2: Write the failing dispatch test**
+
+Create `internal/relay/fanout_test.go`:
+
+```go
+package relay
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// fanoutBackends starts one recording backend per satellite and returns
+// the config pointing at them plus the record of who got which request.
+type backendHit struct {
+ name, method, path, host, body string
+}
+
+func fanoutBackends(t *testing.T) (FanoutConfig, *[]backendHit) {
+ t.Helper()
+ hits := &[]backendHit{}
+ mk := func(name string) string {
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ *hits = append(*hits, backendHit{name, r.Method, r.URL.Path, r.Host, string(b)})
+ w.WriteHeader(299) // unmistakable status: proves the response came from the backend
+ io.WriteString(w, name)
+ }))
+ t.Cleanup(s.Close)
+ return s.URL
+ }
+ return FanoutConfig{
+ Media: mk("media"),
+ Indexer: mk("indexer"),
+ Notify: mk("notify"),
+ Unfurl: mk("unfurl"),
+ APBridge: mk("apbridge"),
+ }, hits
+}
+
+func TestFanoutDispatch(t *testing.T) {
+ cfg, hits := fanoutBackends(t)
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ *hits = append(*hits, backendHit{"inner", r.Method, r.URL.Path, r.Host, ""})
+ w.WriteHeader(298)
+ })
+ h, err := Fanout(inner, cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ front := httptest.NewServer(h)
+ t.Cleanup(front.Close)
+
+ cases := []struct {
+ method, path, want string
+ status int
+ }{
+ {"GET", "/media/abc123", "media", 299},
+ {"POST", "/media", "media", 299},
+ {"GET", "/api/search?q=x", "indexer", 299},
+ {"GET", "/stream/notify", "notify", 299},
+ {"GET", "/unfurl?url=https%3A%2F%2Fexample.com", "unfurl", 299},
+ {"GET", "/.well-known/webfinger?resource=acct:paul", "apbridge", 299},
+ {"GET", "/.well-known/host-meta", "apbridge", 299},
+ {"GET", "/users/paul", "apbridge", 299},
+ {"POST", "/inbox", "apbridge", 299},
+ {"GET", "/ap/objects/1", "apbridge", 299},
+ // NOT proxied — the relay's own surface:
+ {"GET", "/subscribe", "inner", 298},
+ {"GET", "/info", "inner", 298},
+ {"GET", "/events", "inner", 298},
+ {"GET", "/.well-known/woodstar.json", "inner", 298},
+ {"GET", "/.well-known/apple-app-site-association", "inner", 298},
+ {"GET", "/api", "inner", 298}, // Caddy /api/* does not match bare /api
+ {"GET", "/users", "inner", 298}, // /users/* does not match bare /users
+ {"GET", "/inboxes", "inner", 298}, // /inbox is an exact match
+ }
+ for _, c := range cases {
+ *hits = (*hits)[:0]
+ req, _ := http.NewRequest(c.method, front.URL+c.path, strings.NewReader("payload"))
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("%s %s: %v", c.method, c.path, err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != c.status {
+ t.Errorf("%s %s: status %d, want %d", c.method, c.path, resp.StatusCode, c.status)
+ }
+ if len(*hits) != 1 || (*hits)[0].name != c.want {
+ t.Errorf("%s %s: hit %+v, want backend %q", c.method, c.path, *hits, c.want)
+ }
+ }
+}
+
+func TestFanoutPreservesHostMethodBody(t *testing.T) {
+ cfg, hits := fanoutBackends(t)
+ h, err := Fanout(http.NotFoundHandler(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ front := httptest.NewServer(h)
+ t.Cleanup(front.Close)
+
+ req, _ := http.NewRequest("POST", front.URL+"/media", strings.NewReader("blobbytes"))
+ req.Host = "woodstar.app" // the Host the edge delivers
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if len(*hits) != 1 {
+ t.Fatalf("hits: %+v", *hits)
+ }
+ got := (*hits)[0]
+ if got.method != "POST" || got.body != "blobbytes" || got.host != "woodstar.app" {
+ t.Errorf("backend saw %+v; want POST, body blobbytes, host woodstar.app", got)
+ }
+}
+
+func TestFanoutPartialConfig(t *testing.T) {
+ // Only media set: media paths proxy, all other satellite paths fall
+ // through to inner (never a 502 to a nil proxy).
+ media := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(299)
+ }))
+ t.Cleanup(media.Close)
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(298) })
+ h, err := Fanout(inner, FanoutConfig{Media: media.URL})
+ if err != nil {
+ t.Fatal(err)
+ }
+ front := httptest.NewServer(h)
+ t.Cleanup(front.Close)
+ for path, want := range map[string]int{"/media/x": 299, "/api/x": 298, "/inbox": 298} {
+ resp, err := http.Get(front.URL + path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != want {
+ t.Errorf("%s: status %d, want %d", path, resp.StatusCode, want)
+ }
+ }
+}
+
+func TestFanoutRejectsBadUpstream(t *testing.T) {
+ if _, err := Fanout(http.NotFoundHandler(), FanoutConfig{Media: "://not-a-url"}); err == nil {
+ t.Fatal("want error for unparseable upstream URL")
+ }
+}
+```
+
+- [ ] **Step 3: Run the test to verify it fails**
+
+```bash
+cd /home/paulca/github.com/woodstarapp/woodstar/.worktrees/w0-fanout
+go test ./internal/relay/ -run TestFanout -count=1
+```
+
+Expected: FAIL — `undefined: FanoutConfig`, `undefined: Fanout`.
+
+- [ ] **Step 4: Implement `internal/relay/fanout.go`**
+
+```go
+package relay
+
+// Fan-out: the relay optionally fronts its satellite services,
+// reproducing the Caddy path routing that prod and the devbox do today
+// (infra/devbox-init.sh's block is the authoritative route set). On
+// CARLOS, the edge routes by Host only, so the relay carries the paths.
+// Everything here is opt-in: a zero FanoutConfig returns inner untouched.
+//
+// Spec: platform repo docs/superpowers/specs/
+// 2026-08-12-woodstar-carloku-migration-design.md §6.
+
+import (
+ "fmt"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "strings"
+)
+
+// FanoutConfig names the satellite base URLs (empty = that satellite is
+// not fronted; its paths fall through to the relay) and the public apex
+// domain (empty = no host-aware handling; Task 3).
+type FanoutConfig struct {
+ Media string
+ Indexer string
+ Notify string
+ Unfurl string
+ APBridge string
+ Domain string
+}
+
+// fanout dispatches by (host, path); zero-value fields disable their leg.
+type fanout struct {
+ inner http.Handler
+ media http.Handler
+ indexer http.Handler
+ notify http.Handler
+ unfurl http.Handler
+ apbridge http.Handler
+ domain string // lowercase apex, "" = host handling off
+}
+
+// Fanout wraps inner with the satellite path dispatch. A zero cfg
+// returns inner unchanged (the byte-identical default).
+func Fanout(inner http.Handler, cfg FanoutConfig) (http.Handler, error) {
+ if cfg == (FanoutConfig{}) {
+ return inner, nil
+ }
+ f := &fanout{inner: inner, domain: strings.ToLower(strings.TrimSpace(cfg.Domain))}
+ for _, p := range []struct {
+ name, target string
+ dst *http.Handler
+ }{
+ {"media", cfg.Media, &f.media},
+ {"indexer", cfg.Indexer, &f.indexer},
+ {"notify", cfg.Notify, &f.notify},
+ {"unfurl", cfg.Unfurl, &f.unfurl},
+ {"apbridge", cfg.APBridge, &f.apbridge},
+ } {
+ if p.target == "" {
+ continue
+ }
+ u, err := url.Parse(p.target)
+ if err != nil || u.Scheme == "" || u.Host == "" {
+ return nil, fmt.Errorf("fanout: %s upstream %q: not an absolute URL", p.name, p.target)
+ }
+ proxy := httputil.NewSingleHostReverseProxy(u)
+ // Immediate flush so /stream/* SSE events cross the hop as they
+ // are written; harmless for ordinary responses.
+ proxy.FlushInterval = -1
+ *p.dst = proxy
+ }
+ return f, nil
+}
+
+func (f *fanout) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ // Host-aware wildcard handling is added in Task 3; path dispatch
+ // applies to every host that reaches the relay.
+ f.dispatch(w, r)
+}
+
+// dispatch mirrors the Caddy handle blocks: longest-specificity path
+// checks, satellite legs falling through to the relay when unset.
+func (f *fanout) dispatch(w http.ResponseWriter, r *http.Request) {
+ p := r.URL.Path
+ switch {
+ case f.media != nil && (p == "/media" || strings.HasPrefix(p, "/media/")):
+ f.media.ServeHTTP(w, r)
+ case f.indexer != nil && strings.HasPrefix(p, "/api/"):
+ f.indexer.ServeHTTP(w, r)
+ case f.notify != nil && strings.HasPrefix(p, "/stream/"):
+ f.notify.ServeHTTP(w, r)
+ case f.unfurl != nil && (p == "/unfurl" || strings.HasPrefix(p, "/unfurl/")):
+ f.unfurl.ServeHTTP(w, r)
+ case f.apbridge != nil && (p == "/.well-known/webfinger" ||
+ p == "/.well-known/host-meta" ||
+ strings.HasPrefix(p, "/users/") ||
+ p == "/inbox" ||
+ strings.HasPrefix(p, "/ap/")):
+ f.apbridge.ServeHTTP(w, r)
+ default:
+ f.inner.ServeHTTP(w, r)
+ }
+}
+
+// requestHost returns r.Host lowercased with any port stripped.
+func requestHost(r *http.Request) string {
+ h := strings.ToLower(r.Host)
+ if host, _, err := net.SplitHostPort(h); err == nil {
+ return host
+ }
+ return h
+}
+```
+
+(`requestHost` is used by Task 3; declaring it here keeps Task 3's diff
+handler-only. If `go vet` flags it unused before Task 3 lands in the
+same branch, that is expected within this task only — it is used two
+tasks later; alternatively fold this function into Task 3's diff.)
+Note: `/media*` and `/unfurl*` in Caddy are literal-prefix wildcards;
+the deployed client only ever requests `/media`, `/media/<hash>`,
+`/unfurl?url=…`, and `/img?u=…`, so exact + `/`-prefix matching above
+is the honest equivalent. (Correction from final review: a hypothetical
+`/mediafoo` falling to the relay does NOT 404 — extension-less unknown
+paths take the SPA fallback and return 200 HTML. The narrowing stands
+because the client never emits such paths, not because the fallback is
+harmless.)
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+```bash
+go test ./internal/relay/ -run TestFanout -count=1
+```
+
+Expected: PASS (all four tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/relay/fanout.go internal/relay/fanout_test.go
+git commit -m "relay: opt-in satellite fan-out — Caddy's path routing, relay-side"
+```
+
+---
+
+### Task 2: SSE streams cross the hop unbuffered
+
+**Files:**
+- Modify: `internal/relay/fanout_test.go` (add one test)
+
+**Interfaces:**
+- Consumes: `Fanout` + `FanoutConfig` from Task 1 (the
+ `FlushInterval = -1` behavior this test locks in).
+- Produces: nothing new — a regression lock.
+
+- [ ] **Step 1: Write the failing-or-locking SSE test**
+
+Append to `internal/relay/fanout_test.go`:
+
+```go
+func TestFanoutStreamsSSEUnbuffered(t *testing.T) {
+ // The notify hop must deliver each SSE event as it is written, not
+ // on backend close — the whole point of proxy.FlushInterval = -1.
+ firstEventOut := make(chan struct{})
+ release := make(chan struct{})
+ notify := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ io.WriteString(w, "data: one\n\n")
+ w.(http.Flusher).Flush()
+ close(firstEventOut)
+ <-release // hold the stream open: reading "one" must not need EOF
+ }))
+ t.Cleanup(func() { close(release); notify.Close() })
+
+ h, err := Fanout(http.NotFoundHandler(), FanoutConfig{Notify: notify.URL})
+ if err != nil {
+ t.Fatal(err)
+ }
+ front := httptest.NewServer(h)
+ t.Cleanup(front.Close)
+
+ resp, err := http.Get(front.URL + "/stream/notify")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ <-firstEventOut
+ buf := make([]byte, 64)
+ type readResult struct {
+ n int
+ err error
+ }
+ got := make(chan readResult, 1)
+ go func() {
+ n, err := resp.Body.Read(buf)
+ got <- readResult{n, err}
+ }()
+ select {
+ case r := <-got:
+ if r.err != nil || !strings.Contains(string(buf[:r.n]), "data: one") {
+ t.Fatalf("read %q err=%v; want the first event", buf[:r.n], r.err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("first SSE event did not arrive while the stream was open — proxy is buffering")
+ }
+}
+```
+
+Add `"time"` to the test file's imports.
+
+- [ ] **Step 2: Run it**
+
+```bash
+go test ./internal/relay/ -run TestFanoutStreamsSSE -count=1
+```
+
+Expected: PASS immediately (Task 1 set `FlushInterval = -1`). If it
+FAILS on the timeout, the flush setting was dropped — restore
+`proxy.FlushInterval = -1` in `Fanout` and re-run.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/relay/fanout_test.go
+git commit -m "relay: lock SSE pass-through flushing with a held-open stream test"
+```
+
+---
+
+### Task 3: Host-aware wildcard behavior
+
+**Files:**
+- Modify: `internal/relay/fanout.go` (ServeHTTP + one new method)
+- Test: `internal/relay/fanout_test.go` (add tests)
+
+**Interfaces:**
+- Consumes: `fanout` struct, `dispatch`, `requestHost` from Task 1.
+- Produces: the complete `Fanout` behavior Task 4 wires flags to.
+
+- [ ] **Step 1: Write the failing host tests**
+
+Append to `internal/relay/fanout_test.go`:
+
+```go
+func TestFanoutWildcardHosts(t *testing.T) {
+ cfg, hits := fanoutBackends(t)
+ cfg.Domain = "woodstar.app"
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ *hits = append(*hits, backendHit{"inner", r.Method, r.URL.Path, r.Host, ""})
+ w.WriteHeader(298)
+ })
+ h, err := Fanout(inner, cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ front := httptest.NewServer(h)
+ t.Cleanup(front.Close)
+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse // inspect redirects, don't follow
+ }}
+
+ cases := []struct {
+ host, path string
+ wantStatus int
+ wantLocation string // "" = no redirect expected
+ wantBackend string // "" = redirect, no backend hit
+ }{
+ // apex: ordinary dispatch, no host handling
+ {"woodstar.app", "/info", 298, "", "inner"},
+ {"woodstar.app", "/media/x", 299, "", "media"},
+ // www: permanent redirect to apex, URI preserved
+ {"www.woodstar.app", "/some/path?q=1", http.StatusMovedPermanently, "https://woodstar.app/some/path?q=1", ""},
+ // one-label subdomain: the Bluesky handle file goes to the bridge
+ {"paul.woodstar.app", "/.well-known/atproto-did", 299, "", "apbridge"},
+ // one-label subdomain, anything else: temporary redirect to the profile
+ {"paul.woodstar.app", "/", http.StatusFound, "https://woodstar.app/paul", ""},
+ {"paul.woodstar.app", "/anything/deep?x=1", http.StatusFound, "https://woodstar.app/paul", ""},
+ // port on the Host header is stripped before matching
+ {"paul.woodstar.app:443", "/x", http.StatusFound, "https://woodstar.app/paul", ""},
+ // foreign hosts (rehearsal sslip.io routes) get the full app
+ {"woodstar-rehearsal.99.81.104.219.sslip.io", "/media/x", 299, "", "media"},
+ {"woodstar-rehearsal.99.81.104.219.sslip.io", "/info", 298, "", "inner"},
+ // deeper-than-one-label: not the wildcard's business (no TLS there anyway)
+ {"a.b.woodstar.app", "/info", 298, "", "inner"},
+ }
+ for _, c := range cases {
+ *hits = (*hits)[:0]
+ req, _ := http.NewRequest("GET", front.URL+c.path, nil)
+ req.Host = c.host
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("%s %s: %v", c.host, c.path, err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != c.wantStatus {
+ t.Errorf("%s %s: status %d, want %d", c.host, c.path, resp.StatusCode, c.wantStatus)
+ }
+ if loc := resp.Header.Get("Location"); loc != c.wantLocation {
+ t.Errorf("%s %s: Location %q, want %q", c.host, c.path, loc, c.wantLocation)
+ }
+ if c.wantBackend == "" {
+ if len(*hits) != 0 {
+ t.Errorf("%s %s: unexpected backend hit %+v", c.host, c.path, *hits)
+ }
+ } else if len(*hits) != 1 || (*hits)[0].name != c.wantBackend {
+ t.Errorf("%s %s: hits %+v, want %q", c.host, c.path, *hits, c.wantBackend)
+ }
+ }
+}
+
+func TestFanoutAtprotoDIDPreservesHost(t *testing.T) {
+ // The bridge resolves WHICH user's DID from the Host header — the
+ // proxy must hand it through untouched.
+ cfg, hits := fanoutBackends(t)
+ cfg.Domain = "woodstar.app"
+ h, err := Fanout(http.NotFoundHandler(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ front := httptest.NewServer(h)
+ t.Cleanup(front.Close)
+ req, _ := http.NewRequest("GET", front.URL+"/.well-known/atproto-did", nil)
+ req.Host = "paul.woodstar.app"
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if len(*hits) != 1 || (*hits)[0].host != "paul.woodstar.app" {
+ t.Fatalf("bridge saw %+v; want Host paul.woodstar.app", *hits)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify the new tests fail**
+
+```bash
+go test ./internal/relay/ -run TestFanoutWildcard -count=1
+```
+
+Expected: FAIL — www/subdomain cases dispatch instead of redirecting
+(status 298/299 where 301/302 is wanted).
+
+- [ ] **Step 3: Implement the host handling**
+
+In `internal/relay/fanout.go`, replace `ServeHTTP` with:
+
+```go
+func (f *fanout) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if f.domain != "" {
+ host := requestHost(r)
+ if host != f.domain {
+ if host == "www."+f.domain {
+ // Caddy: redir https://woodstar.app{uri} permanent
+ http.Redirect(w, r, "https://"+f.domain+r.URL.RequestURI(), http.StatusMovedPermanently)
+ return
+ }
+ if label, ok := strings.CutSuffix(host, "."+f.domain); ok && label != "" && !strings.Contains(label, ".") {
+ // One label under the apex: paul.woodstar.app.
+ if r.URL.Path == "/.well-known/atproto-did" && f.apbridge != nil {
+ // The one well-known file that makes the subdomain a
+ // Bluesky handle — answered by the bridge, which reads
+ // the user from the Host header.
+ f.apbridge.ServeHTTP(w, r)
+ return
+ }
+ // Caddy: redir https://woodstar.app/{labels.2} temporary
+ http.Redirect(w, r, "https://"+f.domain+"/"+label, http.StatusFound)
+ return
+ }
+ // Foreign or deeper hosts (rehearsal routes, a.b.woodstar.app):
+ // serve the app; the wildcard owns exactly one label.
+ }
+ }
+ f.dispatch(w, r)
+}
+```
+
+- [ ] **Step 4: Run the full relay package**
+
+```bash
+go test ./internal/relay/ -count=1
+```
+
+Expected: PASS (new tests and every pre-existing test).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/relay/fanout.go internal/relay/fanout_test.go
+git commit -m "relay: Caddy-parity wildcard host handling — www, atproto-did, profile redirect"
+```
+
+---
+
+### Task 4: `cmd/relay` flag wiring
+
+**Files:**
+- Modify: `cmd/relay/main.go` (flags + handler wrap; today
+ `log.Fatal(http.ListenAndServe(*addr, srv.Handler()))` is main.go:63)
+
+**Interfaces:**
+- Consumes: `relay.Fanout`, `relay.FanoutConfig` (Tasks 1+3).
+- Produces: the deployable flag surface the migration's units use:
+ `-media-upstream`, `-indexer-upstream`, `-notify-upstream`,
+ `-unfurl-upstream`, `-apbridge-upstream`, `-domain`.
+
+- [ ] **Step 1: Add the flags and the wrap**
+
+In `cmd/relay/main.go`, after the existing flag declarations (below
+`importMax`, before `flag.Parse()`):
+
+```go
+ mediaUp := flag.String("media-upstream", "", "front the media service at this base URL (fan-out: /media*)")
+ indexerUp := flag.String("indexer-upstream", "", "front the indexer at this base URL (fan-out: /api/*)")
+ notifyUp := flag.String("notify-upstream", "", "front the notify service at this base URL (fan-out: /stream/*)")
+ unfurlUp := flag.String("unfurl-upstream", "", "front the unfurl service at this base URL (fan-out: /unfurl*)")
+ apbridgeUp := flag.String("apbridge-upstream", "", "front the AP bridge at this base URL (fan-out: webfinger, host-meta, /users/*, /inbox, /ap/*)")
+ domain := flag.String("domain", "", "public apex domain; with -apbridge-upstream set, serves Caddy-parity subdomain behavior (www redirect, atproto-did via bridge, profile redirects)")
+```
+
+And replace the final serve line (main.go:63):
+
+```go
+ handler, err := relay.Fanout(srv.Handler(), relay.FanoutConfig{
+ Media: *mediaUp,
+ Indexer: *indexerUp,
+ Notify: *notifyUp,
+ Unfurl: *unfurlUp,
+ APBridge: *apbridgeUp,
+ Domain: *domain,
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ if *domain != "" && *apbridgeUp == "" {
+ log.Fatal("-domain requires -apbridge-upstream: subdomains must answer /.well-known/atproto-did (the Bluesky handle contract)")
+ }
+ log.Printf("relay %q listening on %s (db %s) — client at http://localhost%s/", *name, *addr, *db, *addr)
+ log.Fatal(http.ListenAndServe(*addr, handler))
+```
+
+(The pre-existing `log.Printf` line stays; only `srv.Handler()` inside
+`ListenAndServe` changes to `handler`.)
+
+- [ ] **Step 2: Build and run the whole gate**
+
+```bash
+go build ./... && go vet ./... && make test
+gofmt -l cmd/relay internal/relay
+```
+
+Expected: build/vet/test clean; gofmt prints nothing.
+
+- [ ] **Step 3: Manual smoke (optional but cheap)**
+
+```bash
+go run ./cmd/relay -addr 127.0.0.1:7071 -db /tmp/w0-smoke.db \
+ -apbridge-upstream http://127.0.0.1:9999 -domain example.test &
+curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' \
+ -H 'Host: paul.example.test' http://127.0.0.1:7071/x
+kill %1
+```
+
+Expected: `302 https://example.test/paul`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cmd/relay/main.go
+git commit -m "relay: -*-upstream and -domain flags wire the fan-out"
+```
+
+---
+
+### Task 5: PR
+
+- [ ] **Step 1: Push and open the PR**
+
+```bash
+git push -u origin w0-relay-fanout
+gh pr create --title "relay: opt-in satellite fan-out + Caddy-parity wildcard hosts (CARLOS W0)" --body "$(cat <<'EOF'
+On CARLOS the edge routes by Host only, so the relay learns to front its
+satellites: new optional flags -media/-indexer/-notify/-unfurl/-apbridge-upstream
+reproduce the Caddy path fan-out (devbox-init.sh's route set), and -domain
+reproduces the *.woodstar.app block byte-for-byte (www→apex 301,
+/.well-known/atproto-did→bridge with Host preserved, everything else→302
+to the apex profile). All flags unset = byte-identical behavior; dev and
+the devbox are untouched (Caddy keeps the fan-out there).
+
+Spec: platform repo docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md §6.
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)
+EOF
+)"
+```
+
+- [ ] **Step 2: After merge (recorded here, executed at migration time):**
+rebuild all six binaries at the merged sha
+(`GOOS=linux GOARCH=arm64 CGO_ENABLED=0 -trimpath`) and
+`carlos ship -app woodstar-<svc> -kind binary -version <sha> -target linux-arm64 <bin>`
+for relay, media, indexer, notify, unfurl, apbridge. The old `dfe406a`
+shipment predates unfurl/apbridge and is superseded.
+
+## Self-review notes
+
+- Spec §6 coverage: path set ✓ (all nine legs + default), wildcard host
+ behavior ✓ (www 301 URI-preserved, atproto-did→bridge Host-preserved,
+ 302 profile redirect, apex untouched, foreign hosts serve the app),
+ SSE flush ✓ (held-open stream test), WS untouched ✓ (/subscribe
+ dispatches inner; upgrade path unchanged), unset-flags-byte-identical
+ ✓ (zero cfg returns inner; `TestFanoutPartialConfig` covers partial).
+- The `-domain`-requires-`-apbridge-upstream` refusal is deliberate:
+ a subdomain that redirects profiles but 404s atproto-did would
+ silently break Bluesky handles — fail loud at boot instead.
diff --git a/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md b/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md
new file mode 100644
index 000000000..80af54a52
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md
@@ -0,0 +1,424 @@
+# Woodstar → Carloku migration
+
+**Date:** 2026-08-12 · **Status:** draft (design approved in conversation
+2026-08-12; amended same day after re-recon — see §0) · **Requested by:**
+Paul
+
+**Supersedes:** the plan half of
+`docs/superpowers/plans/2026-08-02-plan-woodstar-flagship.md` (its recon
+remains the authoritative description of where Woodstar lives today; its
+task list is replaced by the plan that follows this spec).
+
+**Builds on:** route records (#137), route hostnames/aliases (#151),
+domain attach (#82), wildcard certs (2026-08-05 — the DNS-01 machinery
+this spec extends), and the kass/keymail platform-tenant migrations
+(the playbook).
+
+## 0. Amendment — the 2026-08-02 recon is stale (re-recon 2026-08-12)
+
+Read from woodstar main `b61409a` (`infra/prod-Caddyfile`,
+`infra/devbox-init.sh`, `cmd/`, `web/public/lib/api.js`); prod-box
+actuals still owed to the §7 pre-flight:
+
+- **Six services, not four.** Added since the old plan: `unfurl` (:7075,
+ link-card fetcher; SQLite `unfurl.db` is a disposable cache,
+ deliberately outside litestream) and `apbridge` (:7076, the
+ ActivityPub/Bluesky bridge: webfinger, host-meta, `/users/*`,
+ `/inbox`, `/ap/*`, and `/.well-known/atproto-did` on user subdomains).
+- **apbridge carries identity.** Its SQLite `apbridge.db` is in the
+ litestream set (three replicated DBs, not two), and its `-seed` file
+ (`apbridge.seed`) holds the bridge key material — the federation
+ identity. The seed is a flat file NOT covered by litestream or the
+ media timer; it must be copied at cutover, and §7's pre-flight
+ verifies how (or whether) prod backs it up today. apbridge also takes
+ a required `-domain` flag (`woodstar.app` on the flagship) — the old
+ plan's "no canonical-host pinning anywhere" claim is no longer true —
+ and dials out to `bsky.social` and Bridgy Fed (ordinary egress; fine).
+- **The per-user subdomain / Bluesky behavior is LIVE, not
+ aspirational.** Prod Caddy's `*.woodstar.app` block today:
+ `www` → apex (permanent redirect); `/.well-known/atproto-did` →
+ apbridge (what makes `paul.woodstar.app` a Bluesky handle); everything
+ else → temporary redirect to `https://woodstar.app/<leftmost-label>`
+ (the profile). §5's woodstar-side half is therefore **existing prod
+ behavior W0 must preserve at cutover**, not a follow-up issue.
+- **The fan-out set is wider** (§6 lists it). The reference
+ prod-Caddyfile lacks the `/unfurl*` route the devbox has — box
+ actuals to be verified in pre-flight; the migration includes it
+ either way.
+- The reference Caddyfile's header names `34.241.196.33` while the
+ 2026-08-02 recon verified EIP `52.30.232.124`; pre-flight confirms
+ the live address before any SSH step.
+
+Where this amendment contradicts the sections below, the amendment
+wins; §§2, 5, 6, 7 have been updated in place to match.
+
+## 1. Goal
+
+Woodstar's production — `woodstar.app`, `status.woodstar.app`, and
+working `*.woodstar.app` TLS + routing — serves from the Carloku
+flagship box (`i-092c0c1eea75723cb` / 99.81.104.219), riding carlos
+release channels. Carloku IS the CARLOS platform flagship deployment
+(Paul, 2026-08-12); "migrate to carloku" means onto that box.
+
+Hostnames survive byte-identical: passkeys (RP ID = page domain) and the
+iOS AASA binding (`J5NDF5PCD8.app.woodstar.ios`) are domain-bound. The
+devbox (`dev.woodstar.app`, `i-0c388ec47cf98067a`) stays put — it is the
+dev/canary workflow and is not migrating.
+
+Two deliberate product outcomes ride along:
+
+- The platform gains **delegated-challenge wildcard certs** (§4): the
+ standing answer to "wildcard TLS on a customer domain whose DNS
+ provider the platform must not know". Woodstar is treated as an
+ untrusted third-party customer for this feature even though it is a
+ platform tenant.
+- The platform gains **wildcard aliases** (§5): `*.<domain>` resolving
+ to a primary route, preserving Woodstar's "any subdomain serves the
+ app" contract and enabling the Bluesky handle story
+ (`@paul.woodstar.app` → the user's woodstar profile; atproto handle
+ verification needs `https://paul.woodstar.app/.well-known/atproto-did`
+ to answer over valid TLS).
+
+## 2. Tenancy shape
+
+**Platform tenant**, the keymail/kass pattern:
+
+- Seven carlos apps: `woodstar-relay`, `woodstar-media`,
+ `woodstar-indexer`, `woodstar-notify`, `woodstar-unfurl`,
+ `woodstar-apbridge` (kind binary, linux-arm64) and `woodstar-status`
+ (kind static). Six binaries stay six apps — matches the repo's
+ one-binary-per-service design value.
+- Six bespoke hardened units
+ `woodstar-{relay,media,indexer,notify,unfurl,apbridge}.service`:
+ static system user `woodstar`, harden-host.sh sandbox drop-ins,
+ loopback ports 8451 relay / 8452 media / 8453 indexer / 8454 notify /
+ 8455 unfurl / 8456 apbridge, data under `/data/woodstar` (0700;
+ `/data` 0711). apbridge runs with
+ `-domain woodstar.app -seed /data/woodstar/apbridge.seed` plus
+ relay/media upstream URLs; the seed file is migrated at cutover (§7).
+- Env file `/etc/carlos/apps/woodstar.env` (0600 root) carrying
+ `WOODSTAR_APPLE_APP_ID=J5NDF5PCD8.app.woodstar.ios` — present
+ **before** the DNS flip, or iOS passkey assertions break. No other
+ runtime env; no secrets, no SSM staging, no IMDS allowance (the Go
+ services contain zero AWS SDK; litestream and the media timer run as
+ root).
+- Backups move to the platform data bucket:
+ `s3://carlos-data-ie-271376211898/woodstar/{relay,indexer,apbridge,media}`
+ (host role already has RW — zero IAM/tofu change). The old
+ `woodstar-backups-271376211898` bucket keeps pre-migration history,
+ frozen. Litestream config is extended root-side for the THREE
+ replicated DBs (relay, indexer, apbridge — unfurl's cache is
+ deliberately excluded, per the repo's own rule), NOT enabled until
+ cutover; media backup is a root hourly additive `aws s3 sync` timer.
+- Headroom: the whole stack runs in a t4g.nano today (data in the
+ single-digit MB); the flagship verdict from the 2026-08-02 plan
+ stands, re-checked at rehearsal with all six services.
+
+## 3. Sequencing — after the custom-domain track
+
+Gate: **#137 merged and deployed, then #151, then #82 — live on the
+flagship** — before rehearsal-onward steps. `woodstar.app` and
+`status.woodstar.app` then attach through the product surface
+end-to-end:
+
+- The relay's primary route exists as a **declared route record**
+ (#137 vocabulary); `woodstar.app` attaches as an **alias** to it via
+ the #82/#151 door.
+- `status.woodstar.app` attaches for `woodstar-status` (alias to its
+ primary route, or first-route static attach — whichever the landed
+ door supports for the shape at hand).
+- No `carlos add` over SSM for the public hostnames.
+
+Work that does NOT wait (no code-path overlap with the track): W0 in
+the woodstar repo (§6), and the two platform features (§4, §5) up to
+review — their claim-shape integration point is coordinated with the
+#151/#82 sessions (§10).
+
+## 4. Delegated-challenge wildcard certs
+
+The platform must hold no customer DNS credentials and never know the
+customer's DNS provider. Wildcard issuance is DNS-01, which normally
+requires writing TXT records into the customer zone — squared by
+**ACME challenge delegation via CNAME** (the Cloudflare-for-SaaS
+pattern):
+
+1. The customer creates, once, in their own zone with any provider:
+ `_acme-challenge.<domain>` CNAME →
+ `_acme-challenge.<domain>.<delegation-domain>`, where the delegation
+ domain is a platform-controlled zone (under `oncarlos.com`; exact
+ label fixed in the plan).
+2. At issuance the edge publishes the challenge TXT **in its own zone**
+ at the delegation target, via the DNSimple provider it already has.
+3. Let's Encrypt follows the CNAME from the customer zone into ours,
+ sees the TXT, issues `*.<domain>`.
+
+Properties: no customer credentials; revocable by the customer deleting
+one record; the CNAME's existence is standing proof of domain control —
+strictly stronger evidence than #82's DNS-pointing ruling. This
+retires part of the "bring-your-own apps domain / DNS delegation" price
+tag #82 §9 left unpaid.
+
+Mechanics (all provider-blind, the `dnsProvider` seam is untouched):
+
+- `wildcard.go` computes `challengeFQDN = _acme-challenge.<parent>`
+ today. Delegated parents get a parent → delegation-target mapping;
+ `solveDNS01` publishes at the target. **`waitForTXT` polls the
+ delegation target, not the original name** (amended per final
+ review): we confirm the write we control, and the customer zone's
+ negative-cache TTL is not ours to bound. Consequence, stated:
+ `waitForTXT` is NOT a delegation-setup check — a missing customer
+ CNAME sails through the wait and fails at Let's Encrypt, one failed
+ validation per parent per hour (inside LE limits); the attach-surface
+ CNAME gate below is the real setup check.
+- **Platform parents always win** (final-review ruling): a delegated
+ entry colliding with a static domain or sitting under the apps
+ domain is dropped at `refresh()`, never preferred — one bad claim
+ must not degrade a live platform wildcard's renewal.
+- **Declaration — two additive claim fields** (the #82 session's
+ catch: one bool must not fuse two orthogonal capabilities):
+ - `wildcard: true` — this domain is a **delegated wildcard parent
+ for cert issuance** (§4 only). Useful alone: one wildcard cert can
+ cover several named aliases without per-host CT-logged certs.
+ - `catchall: true` — **derive the `*.<host>` alias entry** to the
+ primary (§5). Requires `wildcard` (a catch-all no cert can serve
+ is refused at the attach surface, not stored).
+ - On a first-route claim (empty `route`), `catchall` aliases the
+ claim's **own materialized primary route** once #137 reconcile
+ mints it — defined here so the empty-route case is never
+ ambiguous. Woodstar sets both fields.
+ - Shape status: **SETTLED with the #151 session** — both fields
+ declared on `internal/domains.Claim` with the orthogonality and
+ refusal rule in doc comments (`worktree-route-hostnames` 086f150).
+ Absent fields = today's behavior byte-identical. Standing rule
+ from that agreement: #151's converge preserves only DECLARED
+ struct fields, so **any new claim field lands on
+ `internal/domains.Claim` in the same change that first writes
+ it**. Routeless claims are ignored by #151's reconcile, so this
+ spec's derivation owns the empty-route+catchall case outright.
+ The edge derives delegated wildcard parents from claims exactly
+ as it derives `<acct>.<apps-domain>` parents from the route
+ table — no bare env list.
+ - The delegation-CNAME stamp joins the domains GET row as an
+ additive `delegation_status` field (name agreed with #151; their
+ row JSON is additive and the CLI tolerates unknown fields), riding
+ the same read path as the `pointing`/`elsewhere` stamps.
+- **Gate:** the attach surface verifies the delegation CNAME exists
+ before enabling (the #82 `pointing`/`elsewhere` stamp spirit) — a
+ missing CNAME is an honest stamp, not a silent hourly issuance
+ failure.
+- Every failure degrades to per-host issuance, like today's wildcard
+ failures — never a dead edge.
+
+## 5. Wildcard aliases
+
+A wildcard cert alone leaves arbitrary subdomains handshaking into the
+edge's no-route response. Woodstar's live contract (prod Caddy wildcard
+block, §0) is per-user subdomains: `paul.woodstar.app` answers
+`/.well-known/atproto-did` (the Bluesky handle) and redirects everything
+else to the apex profile. So:
+
+- `*.<domain>` becomes a `route_aliases`-shaped entry resolving to a
+ named primary route: one-label matching only (a wildcard matches
+ exactly one label, mirroring the cert), explicit named routes/aliases
+ win over the wildcard.
+- Resolution is to the primary's upstream **entry** (the identical map
+ value, not a copied address) — #151/#152's agreed rule — so wildcard
+ traffic inherits multi-upstream health-checked routing the moment
+ #152 lands, with zero wildcard-side changes. Derivation slots beside
+ #151's `ReconcileAliases` (`internal/domains/aliases.go`), which
+ ignores fields it doesn't know.
+- **Parent-derivation exclusion (#151's caution, pinned):** the edge's
+ `currentHosts()` excludes alias-flagged entries from wildcard-cert
+ parent derivation; the derived `*.<host>` entries must carry the same
+ alias flag (`up.alias`) so DNS-01 parent derivation never
+ double-counts a delegated domain.
+- The upstream sees the **original `Host` header** — per-user host
+ interpretation is app logic, and the platform must not foreclose it.
+- Per #151's pinned rule, content/backing derivation uses the primary's
+ host; the request host is TLS/cache-key/metering only. Metering
+ consequence (flagged by the unit-economics session): each concrete
+ subdomain becomes its own usage row attributed to the primary route's
+ account/app — correct, provided alias resolution decides route
+ identity before the metering wrap, which is #151's ordering.
+
+The woodstar-side half — the relay reproducing the Caddy wildcard
+block's host-aware behavior (`www` → apex permanent redirect,
+`/.well-known/atproto-did` → apbridge, anything else → temporary
+redirect to `https://woodstar.app/<leftmost-label>`) — is **live prod
+behavior and part of W0** (§6), not a follow-up. The platform
+guarantees: any `*.woodstar.app` request arrives at the relay with Host
+intact over valid TLS.
+
+## 6. W0 — relay path fan-out (the one woodstar code change)
+
+The edge routes by Host only; the deployed client and the federated
+world expect Caddy's path fan-out on one origin. Fix is relay-side
+(W0a from the 2026-08-02 plan, widened by §0's re-recon):
+
+- Optional flags `-media-upstream`, `-indexer-upstream`,
+ `-notify-upstream`, `-unfurl-upstream`, `-apbridge-upstream` on
+ `cmd/relay`. When set, the relay reverse-proxies:
+ `/media` + `/media/*` → media (including `POST /media` uploads);
+ `/api/*` → indexer; `/stream/*` → notify; `/unfurl*` AND `/img*` →
+ unfurl (`/img` serves every link-card image — omitting it fails as a
+ 200 SPA page inside `<img>` tags, caught in final review);
+ `/.well-known/webfinger`, `/.well-known/host-meta`, `/users/*`,
+ `/inbox`, `/ap/*` → apbridge — the devbox Caddyfile's route set,
+ which is the truthful superset of prod's. `httputil.ReverseProxy`
+ passes WS upgrades and flushes `text/event-stream`.
+- **Host-aware wildcard behavior** (only when fan-out is enabled and
+ the request Host is a one-label subdomain of the relay's domain, not
+ the apex): `www` → permanent redirect to the apex with URI preserved;
+ `/.well-known/atproto-did` → apbridge; everything else → temporary
+ redirect to `https://<apex>/<leftmost-label>` — byte-matching the
+ prod Caddy wildcard block. Apex requests are untouched.
+- Unset = today's behavior byte-identical; dev/devbox unaffected (Caddy
+ keeps the fan-out there).
+- TDD in the woodstar repo; lands via woodstar PR; cutover binaries are
+ the merged W0 sha (the dfe406a shipment already in the bucket is
+ pipeline-validation only, superseded — it predates unfurl and
+ apbridge entirely).
+
+## 7. Rehearsal, cutover, rollback
+
+**Playbook constraints (inherited, standing):** no DNS changes and no
+touching running woodstar services until the gated steps; the flip is
+HELD for Paul's explicit go; rehearsals only ever read COPIES — never
+point a litestream at the live `prod/` prefixes (lineage fork); never
+run old-box and flagship stacks against the same replication prefixes
+at once; no `carlos bootstrap` on the live flagship.
+
+**Old-box access:** the prod box (`ec2-user@52.30.232.124`, SSH-only,
+no SSM) gets an **allow rule for the executing session's SSH** so the
+session drives verification and cutover steps directly.
+
+**Pre-flight (any time, no prod impact):**
+
+- Over SSH (confirming the live address first — §0's IP discrepancy):
+ `systemctl cat` on all six `woodstar-*` units (exact flags/env,
+ especially apbridge's `-domain`/`-seed`/`-bridgy`/`-bsky-api`), real
+ per-service RSS, `ls /opt/woodstar/data` (expect relay.db,
+ indexer.db, apbridge.db, apbridge.seed, unfurl.db, media — anything
+ else is a finding), the box's actual Caddyfile vs the repo reference
+ (`/unfurl*` presence), and `/etc/litestream.yml` actuals (which DBs
+ replicate, and whether `apbridge.seed` has ANY backup today — if
+ not, copy it to the backup bucket immediately as its own pre-flight
+ fix, it is the federation identity).
+- Create the ACME delegation CNAME in Route53 (inert until used);
+ obtain and handshake-verify `*.woodstar.app` on the flagship
+ **before** the flip — issuance does not depend on the A records.
+- Attach `woodstar.app` + `status.woodstar.app` through the door;
+ declare the wildcard alias. The relay's primary route record is
+ materialized and adopted, and all alias entries are derived box-side,
+ pre-flip. Per-host certs for the two named hosts mint on first
+ post-flip hit.
+
+**Rehearsal (copies only):** server-side CopyObject of the backups into
+`carlos-data-ie-…/woodstar-rehearsal/`; litestream restore the three
+DBs + media sync + a seed-file copy on the flagship;
+`PRAGMA integrity_check` on all three; transient units on rehearsal
+ports; an sslip.io rehearsal route. apbridge rehearses with `-bridgy`
+and `-bsky-api` pointed at throwaway values (its own flags exist for
+exactly this) so the rehearsal never dials the real federation. The
+point is the **path-fan-out check through the edge**: feed renders
+restored posts, `/media/*` images load, `/api/search` returns,
+`/stream/*` SSE streams, `/subscribe` WS connects, `/unfurl` answers,
+webfinger + `/users/*` respond from the bridge — plus woodstar's own
+WebKit `console-check.mjs`. Record freeze-to-serving time; tear down
+units, data, routes.
+
+**Cutover (HELD for Paul's explicit go; quiet hour; TTLs already 300):**
+
+Pre-flight invariant (final-review catch): **`status.woodstar.app`'s
+exact-host route MUST exist before the wildcard alias serves** — with
+the alias live and the status route missing, status requests 302 to
+`woodstar.app/status` (an SPA client route) and the status page
+silently becomes the app. Exact-host beats wildcard; verify the
+precedence in rehearsal.
+
+1. Old box (SSH): stop the six `woodstar-*` units → run one final
+ media-backup pass → wait ~30 s for litestream's final WAL frames →
+ stop litestream → copy `apbridge.seed` off the box → verify newest
+ backup timestamps are post-stop.
+2. Flagship: litestream restore the three DBs from `woodstar-backups` +
+ media sync + seed file into `/data/woodstar`; chown woodstar;
+ `PRAGMA integrity_check`; start the six units; enable flagship
+ litestream config + media timer (fresh `carlos-data-ie` prefixes —
+ order is stop-old → restore-new → start-new).
+3. Smoke BEFORE DNS: loopback `/info` 200, AASA correct, webfinger
+ answers from the bridge,
+ `curl --resolve woodstar.app:443:99.81.104.219` through the edge.
+4. Flip Route53 (zone `Z07702842ZC4MUKZR07JB`, same account):
+ `woodstar.app` A and `*.woodstar.app` A → 99.81.104.219. Watch the
+ per-host certs issue.
+5. Paul's browser pass: passkey sign-in, post, image upload + display,
+ search, live notification, DM, link-card unfurl, iOS app AASA
+ validation, status page, a `paul.woodstar.app` profile redirect +
+ its `/.well-known/atproto-did`, and federation (a webfinger lookup
+ from a real Fediverse server or Bridgy Fed still resolving).
+
+**Rollback (any point before Paul blesses it):** stop flagship units +
+its litestream; restart the old box's units + litestream; flip the two
+A records back. Writes accepted by the flagship during the window live
+only in its lineage — keep the window short. Old box: units stopped,
+disk intact, frozen 30 days.
+
+**Named failure shapes:**
+
+- **Degraded-not-broken:** a misconfigured fan-out or alias leaves the
+ site "working" while media/search/notify silently fail. The four-path
+ checks run at rehearsal AND at cutover — never skipped.
+- **Founder trap:** never start units against an empty `/data/woodstar`
+ reachable on the real hostname — the first registrant becomes
+ founder. Restore is explicit; there is no restore-on-boot.
+- **SSE/WS through two proxied hops** (edge → relay → notify): verified
+ in rehearsal, not assumed.
+
+## 8. Testing
+
+- Platform: the full gate (`go build/vet/test ./...`, gofmt, darwin
+ cross-compile) plus delegated-wildcard unit tests (parent→target
+ mapping; TXT published at the target, not the origin; cleanup;
+ degradation to per-host on failure), wildcard-alias tests (one-label
+ matching, named-route precedence, Host preserved to upstream), and
+ parse goldens for the agreed claim-shape extension (additive rule —
+ pre-change claims must still parse).
+- Woodstar: W0 TDD (proxy wiring, SSE flush, WS `/subscribe`
+ untouched); `make test` green; e2e harness where practical.
+- Live verification is §7's rehearsal + cutover checks — verify against
+ the thing changed, with the binary built.
+
+## 9. Post-cutover
+
+- Promote the W0 sha to stable after the 72 h supply-chain bake.
+- Repoint status publishing: `carlos ship -kind static` + promote
+ replaces `scripts/status-publish.sh`.
+- Note woodstar `infra/` tofu drift (records + stopped units outside
+ its state); retire the old prod instance after the 30-day freeze;
+ zone, bucket, devbox, IAM stay.
+- Update woodstar's CLAUDE.md "The boxes" section post-retirement, and
+ its `infra/prod-Caddyfile` reference-copy header (the file describes
+ a box that no longer serves).
+
+## 10. Coordination points
+
+- **Claim-shape: SETTLED 2026-08-12.** `wildcard: true` additive on
+ schema 1, agreed with the #151 session (their branch fa5dc0b carries
+ the field; §4 has the detail). The #151 session pings this one when
+ their console door + CLI land, so deploy-flow tasks can cite exact
+ paths.
+- **Delegation-domain label** (§4): fixed in the plan (a name under
+ `oncarlos.com`).
+- **status.woodstar.app attach shape** (§3): alias vs first-route
+ static — decided against the landed #82 door.
+
+## 11. Out of scope, named
+
+- Edge path-prefix routing (W0b) — relay fan-out covers it.
+- New per-user-subdomain features beyond parity with the live Caddy
+ behavior (which W0 reproduces) — e.g. serving the full app on
+ subdomains rather than redirecting — woodstar repo, later.
+- Devbox migration — stays on Caddy wildcard DNS-01, untouched.
+- DNS record automation — the delegation CNAME and the cutover flip are
+ operator/customer acts, per #82's ruling.
+- Multi-provider DNS on one edge — delegation makes it unnecessary.