carlos / platform Public

spec: Woodstar → Carloku migration — delegated wildcards, wildcard aliases, platform-tenant cutover

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a34b8eac0765356f0c2c92865eb8bcf941bca299 parent 00c3344
1 file changed, +294 −0
  • docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md +294 −0
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..52cfbab4b
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md
@@ -0,0 +1,294 @@
+# Woodstar → Carloku migration
+
+**Date:** 2026-08-12 · **Status:** draft (design approved in conversation
+2026-08-12) · **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).
+
+## 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:
+
+- Five carlos apps: `woodstar-relay`, `woodstar-media`,
+ `woodstar-indexer`, `woodstar-notify` (kind binary, linux-arm64) and
+ `woodstar-status` (kind static). Four binaries stay four apps —
+ matches the repo's one-binary-per-service design value.
+- Four bespoke hardened units `woodstar-{relay,media,indexer,notify}.service`:
+ static system user `woodstar`, harden-host.sh sandbox drop-ins,
+ loopback ports 8451 relay / 8452 media / 8453 indexer / 8454 notify,
+ data under `/data/woodstar` (0700; `/data` 0711).
+- 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,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 two DBs, 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 (~7 MB data); the
+ flagship verdict from the 2026-08-02 plan stands, re-checked at
+ rehearsal.
+
+## 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 original
+ name — resolvers follow the CNAME, so it already works.
+- **Declaration** extends #82's claim vocabulary: a wildcard attach on
+ a claimed domain (a field on the domain claim, or a sibling wildcard
+ claim — shape settled with the #151/#82 sessions, who own it). The
+ edge derives delegated wildcard parents from platform state exactly
+ as it derives `<acct>.<apps-domain>` parents from the route table —
+ no bare env list.
+- **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 current contract (Caddy wildcard)
+is that any subdomain serves the app, and the Bluesky story requires
+`paul.woodstar.app` to reach the relay. 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.
+- 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.
+
+The woodstar-side half — the relay interpreting
+`Host: <user>.woodstar.app` to serve that user's
+`/.well-known/atproto-did` and redirect/show their profile (DESIGN §11's
+`you.woodstar.app` made real, plus the Bluesky DID piece) — is a
+woodstar-repo feature filed as its own issue, NOT a migration blocker.
+The migration guarantees only: 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 expects `/media*`,
+`/api/*`, `/stream/*` on one origin (Caddy path-routes today). Fix is
+relay-side (W0a from the 2026-08-02 plan):
+
+- Optional flags `-media-upstream`, `-indexer-upstream`,
+ `-notify-upstream` on `cmd/relay`. When set, the relay
+ reverse-proxies `/media` + `/media/*` → media, `/api/*` → indexer,
+ `/stream/*` → notify — mirroring Caddy's `prod_routes` block exactly,
+ including `POST /media` uploads. `httputil.ReverseProxy` passes WS
+ upgrades and flushes `text/event-stream`.
+- 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).
+
+## 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: `systemctl cat woodstar-relay` (surprise env/flags), real
+ per-service RSS, `ls /opt/woodstar/data` (nothing beyond
+ relay.db/indexer.db/media).
+- 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 both DBs +
+media sync on the flagship; `PRAGMA integrity_check`; transient units on
+rehearsal ports; an sslip.io rehearsal route. The point is the
+**four-path check through the edge**: feed renders restored posts,
+`/media/*` images load, `/api/search` returns, `/stream/*` SSE streams,
+`/subscribe` WS connects — 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):**
+
+1. Old box (SSH): stop the four `woodstar-*` units → run one final
+ media-backup pass → wait ~30 s for litestream's final WAL frames →
+ stop litestream → verify newest backup timestamps are post-stop.
+2. Flagship: litestream restore both DBs from `woodstar-backups` +
+ media sync into `/data/woodstar`; chown woodstar;
+ `PRAGMA integrity_check`; start the four 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,
+ `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, iOS app AASA validation, status page,
+ and a `*.woodstar.app` subdomain handshake.
+
+**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.
+- File the woodstar-repo per-user-host/Bluesky issue (§5).
+
+## 10. Open coordination points
+
+- **Claim-shape for wildcard/alias declaration** (§4, §5): owned by the
+ #151/#82 sessions' vocabulary; this spec's features integrate with
+ whatever field/record shape is agreed there. Ping those sessions
+ before the plan is written.
+- **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.
+- Woodstar's per-user-host + Bluesky app logic — woodstar repo,
+ follow-up issue.
+- 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.