carlos / platform Public

spec: domains — account-sqid platform domains, custom-domain health, bucket-manifest accounts

Brainstormed with Paul 2026-08-02: <app>.<account-sqid>.<apps-domain>
(vanity account names rejected — squattable; custom domains are the
vanity mechanism), agent-reconciled minting incl. canary hosts, DNS/TLS
health columns, console Domains section. BYO apps-domain named as future.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 81c53beb6b46b6b236a494a72ccfd483b8508764 parent 6736599
1 file changed, +145 −0
  • docs/superpowers/specs/2026-08-02-custom-domains-design.md +145 −0
diff --git a/docs/superpowers/specs/2026-08-02-custom-domains-design.md b/docs/superpowers/specs/2026-08-02-custom-domains-design.md
new file mode 100644
index 000000000..5ee4cd22c
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-02-custom-domains-design.md
@@ -0,0 +1,145 @@
+# Domains — platform domains, custom domains, and their health
+
+Every app has domains as a first-class concept: a **platform domain** it is
+born with, and any number of **custom domains** layered on top — each with
+visible DNS and TLS health in the console, and point-your-DNS-here guidance
+when a custom domain isn't connected yet. Until now domains existed only as
+hand-created routes (`carlos add kass.training …`); this names the concept
+the product already relies on.
+
+## 1. The scheme
+
+ <app>.<account-sqid>.<apps-domain> kass.abs.oncarlos.com
+ <canary>.<app>.<account-sqid>.<apps-domain> pricing.kass.abs.oncarlos.com
+
+- **The account carries the sqid, the app carries its name.** App names are
+ unique per account, not globally — no global name land-grab. Account
+ sqids are minted from the account's registry row id via a fixed sqids
+ alphabet (lowercase, no lookalike characters). **Deliberately not
+ configurable**: vanity account names in the platform domain invite
+ squatting; custom domains are the vanity mechanism.
+- **`PLATFORM_APPS_DOMAIN` is per-deployment config** (flagship:
+ `oncarlos.com`), never a constant. A deployment without it set mints no
+ platform domains — the feature is inert, litestream-convention.
+- DNS: one wildcard A record (`*.<apps-domain>` → edge) covers every depth
+ (RFC 4592 wildcards match multiple labels); LE issues per-host through
+ the edge's existing ACME machinery, no wildcard certs needed.
+- A platform domain proxies to the same backend as the app's primary
+ route. **Canonical-host caveat**, stated honestly: apps that pin their
+ host (keymail's `-host keymail.dev`) will redirect or 404 on the
+ platform domain — correct behavior. Rastrillo apps accept any granted
+ host from day one; for legacy apps the platform domain is the
+ pre-custom-domain bootstrap URL.
+
+## 2. Accounts
+
+- Registry gains an additive `accounts` table (`id INTEGER PRIMARY KEY,
+ name TEXT UNIQUE`) and an `account` column on routes' app linkage.
+ Account **#1 is `ops`**, created by migration — the deployment
+ operator's built-in account. Every app belongs to exactly one account;
+ CLI-added apps default to `ops`.
+- **Account truth originates in the console and travels through the
+ bucket** — never a console→registry write. On claim, the console writes
+ `apps/<app>/account.json` (`{"account": "<name>", "owner":
+ "<address>"}`) to the deployment bucket via its existing store handle.
+ The agent's reconcile pass reads these manifests and materializes
+ accounts + linkage in the registry. A deployment with no console never
+ has manifests: everything lives under `ops`, fully functional.
+
+## 3. The agent reconcile pass
+
+Runs on the agent's existing tick cadence (piggybacked, not a new timer);
+each step is idempotent and additive:
+
+1. **Account materialization**: list `apps/*/account.json`; upsert
+ accounts and app→account linkage.
+2. **Platform-domain minting**: any app with ≥1 route but no platform
+ route gets `<app>.<sqid>.<apps-domain>` minted — same upstream
+ (Socket/Addr/backing) as the app's first ready route (ordered by host,
+ deterministic), `platform=1`.
+ Existing apps are backfilled on the first pass after rollout.
+3. **Canary hosts**: any `canary/<name>` channel pointer in the bucket
+ whose app has no route following it gets
+ `<name>.<app>.<sqid>.<apps-domain>` minted on that channel — closing
+ console slice 3's hostless-canary gap with no console write authority.
+ Retiring the channel (console or CLI) removes the minted route on a
+ later pass (only routes with `platform=1` are ever auto-removed;
+ custom domains are never touched by reconciliation).
+4. **DNS health**: resolve each route host (A/AAAA) and compare against
+ the deployment's edge IPs (`PLATFORM_EDGE_IPS`, default: this box's
+ public IP from IMDS). Stamp additive columns `dns_status`
+ (`pointing` | `elsewhere` | `""` unknown — resolver failure is
+ unknown, never a guess) and `dns_checked_at`.
+5. **TLS health**: stamp `cert_expires_at` from the ACME cache's cert for
+ the host (absent → 0/unknown). The route's existing
+ `status` (`issuing`/`ready`/`failed`) remains the issuance signal.
+
+New additive registry columns on routes: `platform INTEGER NOT NULL
+DEFAULT 0`, `dns_status TEXT NOT NULL DEFAULT ''`, `dns_checked_at
+INTEGER NOT NULL DEFAULT 0`, `cert_expires_at INTEGER NOT NULL DEFAULT
+0`, `account TEXT NOT NULL DEFAULT ''` (app-level linkage carried on the
+route rows, consistent with the registry's route-centric shape).
+
+## 4. Console: the Domains section
+
+Per app page, replacing the raw routes framing:
+
+| Domain | Type | DNS | TLS |
+|---|---|---|---|
+| kass.training | custom | pointing | ready · renews 2026-10-12 |
+| kass.abs.oncarlos.com | platform | pointing | ready |
+
+- Unknowns render as the em-dash-titled-unknown convention (status-page
+ style), never guessed.
+- A custom domain with `dns_status=elsewhere` (or unknown) shows the
+ guidance row: *"Point an A record for kass.training at `<edge-ip>`
+ (all regions: `<ips>`). Checks run every few minutes."*
+- Canary rows keep their canary labeling and now always have a URL.
+- The console writes `apps/<app>/account.json` at claim time (new apps)
+ and backfills manifests for already-claimed apps on first page view
+ after rollout.
+
+## 5. Config surface
+
+- `PLATFORM_APPS_DOMAIN` — the apps domain (unset = no platform domains).
+- `PLATFORM_EDGE_IPS` — comma-separated edge IPs for DNS checks
+ (default: this box's public IP via IMDS).
+- Flagship values: `oncarlos.com` + the EIP. Tito: operator's choice
+ (likely unset initially — titogo owns go.tito.io wholesale).
+
+## 6. Testing & guard rails
+
+- Registry: round-trip tests for every new column; migration idempotence.
+- Sqids: fixed-alphabet stability test (id 1 → same sqid forever — a
+ changed alphabet would re-home every platform domain: the test says so).
+- Reconciler: table-driven over a DirStore + fixture registry — minting
+ (new app, backfill, canary), idempotence (second pass is a no-op),
+ never-touch-custom-domains, removal only of platform-flagged routes for
+ retired canaries, DNS stamping with a fake resolver (unknown on error),
+ manifest materialization incl. malformed-manifest skip-with-log.
+- Console: Domains rendering with fixture routes covering all
+ status combinations; guidance row only for non-pointing custom domains;
+ claim writes the manifest.
+- e2e: claim → manifest → reconcile → platform route exists → edge serves
+ it (DirStore, fake resolver).
+
+## 7. Rollout
+
+1. Code lands (registry, agent reconcile, console section) — green before
+ deploy.
+2. Ship/adopt both deployments (inert until config).
+3. Flagship: set `PLATFORM_APPS_DOMAIN=oncarlos.com` +
+ `PLATFORM_EDGE_IPS`; verify kass/keymail/console/platform-site get
+ platform domains under the right accounts; certs mint on first hit.
+4. Tito: leave unset (inert) until wanted.
+
+## 8. Future work (named, not built)
+
+- **Bring-your-own apps domain**: an account supplies
+ `*.platform.company.com` as its personal apps-domain (delegated via
+ CNAME/NS), replacing `<sqid>.<apps-domain>` for its apps. Requires
+ per-account ACME strategy and DNS-delegation verification.
+- **Attach custom domains from the console** (the write-path slice
+ deliberately excluded here).
+- **Multi-region DNS checks** (per-region edge IP sets once the mesh
+ ports land).