plan: domains — six tasks, frozen sqids, reconciler seams
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+188
−0
docs/superpowers/plans/2026-08-02-plan-domains.md+188 −0
diff --git a/docs/superpowers/plans/2026-08-02-plan-domains.md b/docs/superpowers/plans/2026-08-02-plan-domains.md| new file mode 100644 |
| index 000000000..2fa035ae4 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-02-plan-domains.md |
| @@ -0,0 +1,188 @@ |
| +# Domains 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:** Platform domains (`<app>.<account-sqid>.<apps-domain>`) minted by an agent reconcile pass, custom-domain DNS/TLS health stamped on routes, and a console Domains section — per spec `docs/superpowers/specs/2026-08-02-custom-domains-design.md` (read it first; it governs). |
| + |
| +**Architecture:** Registry grows an `accounts` table and additive route columns. A new `internal/domains` package holds the sqid encoder and a pure `Reconcile` function (store + registry + resolver + clock seams). The agent piggybacks reconciliation on its existing tick; the console writes `apps/<app>/account.json` manifests at claim and renders the Domains section from registry data. |
| + |
| +**Tech Stack:** Go stdlib + existing deps only. Sqid encoding is ~30 lines of base-conversion over a fixed alphabet — do NOT add a dependency. |
| + |
| +## Global Constraints |
| + |
| +- Additive-only migrations (append to the ALTER list in `internal/registry/registry.go`; extend `routeCols`/`scanRoute`/Upsert — the compiler + round-trip tests catch missed sites). |
| +- Inert until configured: no `PLATFORM_APPS_DOMAIN` → no minting, no manifests read, zero behavior change. |
| +- Unknowns are `""`/null never guesses: resolver failure → `dns_status=""`; missing cert → `cert_expires_at=0`. |
| +- Reconciliation NEVER touches custom domains (only `platform=1` routes are ever auto-removed). |
| +- Sqid alphabet is frozen forever: `abcdefghjkmnpqrstuvwxyz` (no i/l/o) — changing it re-homes every platform domain; the stability test says so in its comment. |
| +- Tests: `GOCACHE=/tmp/claude-1001/gocache go test ./...`; unix-socket tests skip sandboxed (repo pattern). Heavy why-comments; commits `<area>: <what> — <why>` + `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`. Work on `main`. |
| + |
| +## File Structure |
| + |
| +- `internal/registry/registry.go` — accounts table, route columns, account helpers |
| +- `internal/domains/sqid.go` + `sqid_test.go` — encoder |
| +- `internal/domains/reconcile.go` + `reconcile_test.go` — the pass |
| +- `internal/host/agent.go` — tick wiring + env |
| +- `internal/console/server.go`, `view.go`, `templates/app.html` (or the app page's template), `store.go` if needed — manifests + Domains section |
| +- `e2e_test.go`, `README.md` — end-to-end + docs |
| + |
| +--- |
| + |
| +### Task 1: Registry — accounts table + route columns |
| + |
| +**Files:** Modify `internal/registry/registry.go`; Test `internal/registry/registry_test.go` |
| + |
| +**Interfaces produced (later tasks consume verbatim):** |
| +```go |
| +// accounts: additive CREATE TABLE IF NOT EXISTS accounts |
| +// (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL); |
| +// migration also INSERT OR IGNOREs ("ops") so it is always id 1. |
| +func (r *Registry) EnsureAccount(name string) (int64, error) // idempotent upsert-by-name, returns id |
| +func (r *Registry) AccountID(name string) (int64, error) // 0, nil when absent |
| +// Route gains: Platform bool, DNSStatus string, DNSCheckedAt int64, |
| +// CertExpiresAt int64, Account string — columns platform, dns_status, |
| +// dns_checked_at, cert_expires_at, account (defaults 0/''/0/0/''). |
| +``` |
| + |
| +- [ ] **Step 1: Failing tests** (model: `TestStatusNamedRoundTrip` in registry_test.go): |
| + |
| +```go |
| +func TestOpsAccountExistsAndEnsureIsIdempotent(t *testing.T) { |
| + reg := openTestRegistry(t) // the file's existing open helper |
| + id, err := reg.AccountID("ops") |
| + if err != nil || id != 1 { |
| + t.Fatalf("ops account: id=%d err=%v, want 1", id, err) |
| + } |
| + a, _ := reg.EnsureAccount("paul") |
| + b, _ := reg.EnsureAccount("paul") |
| + if a != b || a == 1 { |
| + t.Errorf("EnsureAccount not idempotent or collided with ops: %d %d", a, b) |
| + } |
| +} |
| + |
| +func TestDomainColumnsRoundTrip(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + r := registry.Route{Host: "kass.abs.oncarlos.com", Kind: "instance", App: "kass", |
| + Platform: true, DNSStatus: "pointing", DNSCheckedAt: 123, CertExpiresAt: 456, Account: "ops"} |
| + if err := reg.Upsert(r); err != nil { t.Fatal(err) } |
| + got, err := reg.Get(r.Host) |
| + if err != nil { t.Fatal(err) } |
| + if !got.Platform || got.DNSStatus != "pointing" || got.DNSCheckedAt != 123 || |
| + got.CertExpiresAt != 456 || got.Account != "ops" { |
| + t.Errorf("round-trip lost fields: %+v", got) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run — FAIL.** **Step 3: Implement** (append 5 ALTERs + accounts CREATE + ops INSERT OR IGNORE to the migration slice; extend Route struct, routeCols, scanRoute, Upsert; add the two account funcs). **Step 4: PASS, whole package green.** **Step 5: Commit** `registry: accounts + domain-health columns — domains become data, not lore`. |
| + |
| +### Task 2: Sqid encoder |
| + |
| +**Files:** Create `internal/domains/sqid.go`; Test `internal/domains/sqid_test.go` |
| + |
| +**Interfaces produced:** `func Sqid(id int64) string` — base-23 over the frozen alphabet `abcdefghjkmnpqrstuvwxyz`, minimum length 3 (left-pad with alphabet[0] equivalents by offsetting: encode `id + 23*23` so ids 1..N are always ≥3 chars — pick ANY deterministic ≥3-char scheme and PIN it with golden tests; the values in the test below are computed from your implementation ONCE and then frozen). |
| + |
| +- [ ] **Step 1: Failing test:** |
| + |
| +```go |
| +func TestSqidGolden(t *testing.T) { |
| + // FROZEN. If this test fails you are re-homing every platform domain |
| + // ever minted. Do not "fix" the expectations; fix your code. |
| + golden := map[int64]string{} |
| + for _, id := range []int64{1, 2, 23, 24, 529, 1000} { |
| + s := domains.Sqid(id) |
| + if len(s) < 3 { t.Errorf("Sqid(%d)=%q shorter than 3", id, s) } |
| + for _, c := range s { |
| + if !strings.ContainsRune("abcdefghjkmnpqrstuvwxyz", c) { |
| + t.Errorf("Sqid(%d)=%q uses %q outside the frozen alphabet", id, s, c) |
| + } |
| + } |
| + if prev, dup := goldenSeen[s]; dup { t.Errorf("collision: %d and %d both %q", prev, id, s) } |
| + goldenSeen[s] = id |
| + golden[id] = s |
| + } |
| + t.Logf("golden set (freeze these into the test on first green run): %v", golden) |
| +} |
| +``` |
| + |
| +(After first green run, replace the dynamic check with literal `golden` expectations and commit those — that's the freeze.) |
| + |
| +- [ ] **Step 2-4: FAIL → implement → PASS + freeze goldens.** **Step 5: Commit** `domains: sqid — frozen alphabet, frozen goldens`. |
| + |
| +### Task 3: The reconcile pass |
| + |
| +**Files:** Create `internal/domains/reconcile.go`; Test `internal/domains/reconcile_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Task 1 registry API; Task 2 `Sqid`; `release.Store` (Get/Put/List); `release.Channel`-style pointer listing (list `apps/<app>/channels/canary/` keys via `Store.List`). |
| +- Produces: |
| + |
| +```go |
| +type Config struct { |
| + AppsDomain string // "" = whole pass inert |
| + EdgeIPs []string // for DNS checks |
| + Resolve func(host string) ([]string, error) // seam; nil = skip DNS stamping |
| + AcmeDir string // edge cert cache; "" = skip TLS stamping |
| + Now func() time.Time |
| +} |
| +// Reconcile runs the spec §3 steps in order, idempotently. Logs and |
| +// continues on per-item errors; returns only setup-level failure. |
| +func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg Config) error |
| +``` |
| + |
| +- [ ] **Step 1: Failing tests** — table-driven; the essential cases (write them all before implementing): |
| + |
| +```go |
| +// helpers: dirStore(t), regWith(t, routes...), manifest(t, s, app, account, owner) |
| +func TestReconcileMintsPlatformDomainUnderOps(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| + App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |
| + err := domains.Reconcile(ctx, s, reg, cfg(t)) // cfg: AppsDomain oncarlos.test, fake resolver |
| + // want: route "kass.aaa.oncarlos.test" (ops sqid = Sqid(1)) exists, |
| + // Platform=true, Addr copied, Account "ops"; second Reconcile = no new routes. |
| +} |
| +func TestReconcileUsesManifestAccount(t *testing.T) // manifest paul → kass.<Sqid(paulID)>.… |
| +func TestReconcileMintsCanaryHost(t *testing.T) // canary/pricing pointer, no follower → pricing.kass.<sqid>.… on that channel |
| +func TestReconcileRemovesRetiredCanaryHostOnly(t *testing.T) // pointer gone → platform-minted follower removed; a CUSTOM route on same channel untouched |
| +func TestReconcileNeverTouchesCustomDomains(t *testing.T) // custom route with dead app: still present after pass |
| +func TestReconcileStampsDNS(t *testing.T) // resolver → pointing / elsewhere / error→"" (unknown, never a guess) |
| +func TestReconcileStampsCertExpiry(t *testing.T) // fixture PEM in AcmeDir → CertExpiresAt; absent → 0 |
| +func TestReconcileInertWithoutAppsDomain(t *testing.T) // AppsDomain "" → registry byte-identical |
| +func TestReconcileSkipsMalformedManifest(t *testing.T) // bad JSON → logged skip, others processed |
| +``` |
| + |
| +Cert fixture: generate a self-signed cert in the test (`crypto/x509` + `ecdsa`, NotAfter pinned) and write it PEM-encoded at `<AcmeDir>/<host>` — read `internal/edge`'s acme cache layout first (autocert stores under the cache dir keyed by host; match what `golang.org/x/crypto/acme/autocert` actually writes: the private-key+cert bundle under key `<host>`; parse leniently — first CERTIFICATE PEM block wins, unparseable → 0). |
| + |
| +- [ ] **Step 2: FAIL. Step 3: Implement** — order per spec §3: manifests (`s.List(ctx, "apps/")`, suffix `/account.json`) → EnsureAccount + stamp `Account` on the app's routes → platform minting (apps = distinct non-static, non-`svc/` App values; skip apps whose only routes are platform-minted; upstream from first ready route ordered by host; host `fmt.Sprintf("%s.%s.%s", app, Sqid(acctID), cfg.AppsDomain)`) → canary minting/removal (channel pointers under `apps/<app>/channels/canary/`; follower = any route with that Channel; removal only `Platform && Channel==that`) → DNS stamp (compare resolved set ∩ EdgeIPs ≠ ∅ → pointing, else elsewhere; error → "") → cert stamp. **Step 4: PASS.** **Step 5: Commit** `domains: reconcile — the bucket decides, the agent materializes`. |
| + |
| +### Task 4: Agent wiring |
| + |
| +**Files:** Modify `internal/host/agent.go`; Test `internal/host/host_test.go` |
| + |
| +**Interfaces:** Consumes Task 3's `Reconcile`+`Config`. Produces: env contract `PLATFORM_APPS_DOMAIN` (unset = inert — document in the agent doc comment beside the STATUS_* block), `PLATFORM_EDGE_IPS` (comma-separated; default = box public IP via the existing IMDS-read helper pattern, best-effort, nil on failure), wired into the adopt tick (the hourly pass) AND the 5-minute status tick's cadence — pick the status tick (5 min) and say why in a comment (DNS guidance says "checks run every few minutes"). Real resolver: `net.DefaultResolver.LookupHost` with 3s timeout; AcmeDir from the edge's acme cache path (`/data/acme` — confirm against `internal/edge`'s actual default and use the same constant/source). |
| + |
| +- [ ] **Step 1: Failing test** — extract `domainsTickConfig(getenv func(string) string, publicIP string) domains.Config` (pure) and test: unset domain → zero Config; set → fields populated, EdgeIPs parsed, default IP fallback. (The tick wrapper itself follows the statusLoop convention: thin, untested, like adoptLoop.) |
| +- [ ] **Step 2-4: FAIL → wire → PASS** (`go test ./internal/host/ ./internal/domains/`). **Step 5: Commit** `host: domains reconcile rides the status tick — inert until PLATFORM_APPS_DOMAIN`. |
| + |
| +### Task 5: Console — manifests + Domains section |
| + |
| +**Files:** Modify `internal/console/server.go`, `view.go`, the app-page template; Test `internal/console/server_test.go` |
| + |
| +**Interfaces:** Consumes registry columns (Task 1) via the console's existing registry read; `release.Store` for manifests. Produces: (a) claim handler (`POST /apps/new`) additionally Puts `apps/<app>/account.json` = `{"account":"<owner-address>","owner":"<owner-address>"}` (account name = the claiming address — one account per user for now, matching the spec's console-originates-truth; use the overwrite-safe put idiom from PublishFleet); (b) app-page backfill: on GET, if the viewer owns the app and no manifest exists, write it (logged, best-effort); (c) `DomainView{Domain, Type, DNS, TLS string; Guidance string}` in view.go — Type "platform"/"custom" from Route.Platform; DNS from DNSStatus ("" → unknown em-dash); TLS from Status + CertExpiresAt ("ready · renews <date>"); Guidance non-empty only for custom+non-pointing: `Point an A record for <host> at <ips>. Checks run every few minutes.` — EdgeIPs surface via a console env `PLATFORM_EDGE_IPS` (same var, read at console start; unset → guidance says "at this deployment's edge IP"); (d) the app template's routes table becomes the Domains table (Domain/Type/DNS/TLS columns, guidance row beneath non-pointing customs, canary labeling preserved). |
| + |
| +- [ ] **Step 1: Failing tests:** claim-writes-manifest (inspect DirStore after POST); backfill-on-view; `TestDomainViewStates` table-driven over Route fixtures (platform pointing ready / custom elsewhere → guidance / custom unknown → em-dash + guidance / canary row keeps label); keyless mode still renders (read-only). |
| +- [ ] **Step 2-4: FAIL → implement → PASS** (whole console package). **Step 5: Commit** `console: domains — the product surface for what routes always were`. |
| + |
| +### Task 6: e2e + docs |
| + |
| +**Files:** Modify `e2e_test.go`, `README.md` |
| + |
| +- [ ] **Step 1:** Extend the e2e: after the existing flow, write an account manifest + run one `domains.Reconcile` with a fake resolver against the DirStore+registry, assert the platform route exists and the edge serves it (httptest, Host header = the minted domain over the echo backend). **Step 2:** README gains a "Domains" section: the scheme, ops default, config surface (`PLATFORM_APPS_DOMAIN`, `PLATFORM_EDGE_IPS`), the canonical-host caveat, inertness. **Step 3:** Full suite `go test ./... -count=1` + vet + build green (note sandbox skips). **Step 4: Commit** `domains: e2e + docs — every app is born addressable`. |
| + |
| +--- |
| + |
| +## Deployment follow-ups (NOT in plan) |
| +Ship/adopt both boxes; flagship env `PLATFORM_APPS_DOMAIN=oncarlos.com` + `PLATFORM_EDGE_IPS=99.81.104.219` (agent + console units); verify kass/keymail/console/platform-site mint under the right accounts; certs on first hit; Tito stays unset. |
| + |
| +## Self-review notes |
| +Spec coverage: §1→T2/T3/T6, §2→T1/T3/T5, §3→T3/T4, §4→T5, §5→T4/T5+follow-ups, §6→each task's tests, §7→follow-ups, §8 explicitly not built. Names cross-checked: `Sqid`, `Reconcile`, `Config`, `EnsureAccount`, `AccountID`, route fields `Platform/DNSStatus/DNSCheckedAt/CertExpiresAt/Account`, `DomainView` — consistent. Golden-freeze procedure in T2 is deliberate (values depend on the implementation's chosen offset scheme; freezing after first green is the contract). |