carlos / platform Public

spec: woodstar-carloku amendment — six-service re-recon, apbridge identity, agreed wildcard claim shape

Re-recon of woodstar main b61409a: unfurl + apbridge services added since
the 2026-08-02 plan; apbridge.db joins the litestream set and
apbridge.seed is the federation identity; the per-user-subdomain/Bluesky
behavior is live prod Caddy behavior and becomes part of W0. Claim shape
wildcard:true agreed with the #151 session (their fa5dc0b); HA
entry-identity and metering-ordering notes pinned.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9d594fee26a3c378d49b54d023e3f202a73566de parent a34b8ea
1 file changed, +162 −68
  • docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md +162 −68
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
index 52cfbab4b..51f4b04a7 100644
--- a/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md
+++ b/docs/superpowers/specs/2026-08-12-woodstar-carloku-migration-design.md
@@ -1,7 +1,8 @@
# Woodstar → Carloku migration
**Date:** 2026-08-12 · **Status:** draft (design approved in conversation
-2026-08-12) · **Requested by:** Paul
+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
@@ -13,6 +14,44 @@ 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
@@ -44,14 +83,19 @@ Two deliberate product outcomes ride along:
**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`:
+- 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,
- data under `/data/woodstar` (0700; `/data` 0711).
+ 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
@@ -59,15 +103,16 @@ Two deliberate product outcomes ride along:
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}`
+ `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 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.
+ 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
@@ -119,10 +164,15 @@ Mechanics (all provider-blind, the `dnsProvider` seam is untouched):
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
+- **Declaration — shape AGREED with the #151 session (2026-08-12):**
+ additive optional `wildcard: true` on the domain claim
+ (`internal/domains.Claim` gains `Wildcard bool
+ `json:"wildcard,omitempty"`` — landed on `worktree-route-hostnames`
+ fa5dc0b; absent = today's behavior byte-identical; #151's converge
+ path preserves the field on route repoints). The one field means
+ both halves: "this domain is a delegated wildcard parent for cert
+ issuance" (§4) and "derive a `*.<host>` alias entry to the primary"
+ (§5). The edge derives delegated wildcard parents from claims 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
@@ -135,44 +185,70 @@ Mechanics (all provider-blind, the `dnsProvider` seam is untouched):
## 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:
+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.
-
-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.
+ 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 expects `/media*`,
-`/api/*`, `/stream/*` on one origin (Caddy path-routes today). Fix is
-relay-side (W0a from the 2026-08-02 plan):
+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` 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`.
+ `-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*` → unfurl;
+ `/.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).
+ pipeline-validation only, superseded — it predates unfurl and
+ apbridge entirely).
## 7. Rehearsal, cutover, rollback
@@ -189,9 +265,16 @@ 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).
+- 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.
@@ -202,33 +285,41 @@ session drives verification and cutover steps directly.
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.
+`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):**
-1. Old box (SSH): stop the four `woodstar-*` units → run one final
+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 → 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
+ 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,
+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, iOS app AASA validation, status page,
- and a `*.woodstar.app` subdomain handshake.
+ 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
@@ -269,15 +360,17 @@ disk intact, frozen 30 days.
- 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).
+- 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. Open coordination points
+## 10. 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.
+- **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
@@ -286,8 +379,9 @@ disk intact, frozen 30 days.
## 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.
+- 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.