carlos / platform Public

plan: domains — six tasks, frozen sqids, reconciler seams

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 1e1f04816b78f4607707a914a5db8cd7e248df85 parent 54b487e
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).