Files
member-console/docs/models/domains-registry.md
T
cgalo5758 ad7a219adf Enforce schema and boot invariants
Enforce 10j's verified gaps (schema-hardening change):

- Migration 00010: partial unique indexes for one default pool and one
  primary assignment per workspace, plus CHECKs pinning
  pool/provider/subscription vocabularies and provider lifecycle
  timestamps.
- Workspace creation shares a transactional provisioning function;
  extension validates its target pool; last-tier deletion of a defaulted
  ladder is guarded; signup completes plan-less on a broken ladder.
- Boot asserts integration slug parity and validates declared config
  enums; Stripe invoice amounts are range-checked; domain cancellation
  runs a final evidence probe; rule authoring is additive-only.
2026-08-22 18:02:46 -05:00

13 KiB

title, audience, summary
title audience summary
Domains Registry
developer
Which DNS names the deployment may serve: claims as subtree ownership, placements as the only thing that serves, TXT verification, and the anti-abuse ledger for external domains.

Domains Registry

Purpose

The registry decides which DNS names this deployment is allowed to serve, and it does so with two nouns that must never be conflated. A claim is ownership of a DNS subtree — the name and everything beneath it — held by a workspace. A placement wires one exact name to one provider resource (a FedWiki site, say), and only a placement serves anything: a claim with no placements serves zero names, and the TLS proxy's mid-handshake question — "may I mint a certificate for this host?" — is answered by an exact-match placement lookup at GET /domains/ask.

Claims come in three kinds. An operator_root claim is deployment infrastructure (the hosted apex, say wiki.example), owned by the system tenant and never releasable. A member claim is carved from inside an operator root — a member taking alice.wiki.example — subject to name policy. An external claim is a member bringing their own domain: it must be proven by publishing a DNS TXT challenge token, is watched by a verification workflow until a deadline, and is governed by an anti-abuse apparatus — an evidence latch (did a probe ever see the challenge?), an abandonment ledger (walk-aways with no evidence are counted against the member), and initiation budgets (how many claims a workspace may open per window). Every budget keys on the workspace, so the abuse model rests on workspaces being scarce — an assumption this card makes explicit rather than one any document guarantees. Two probes watch a pending external claim: the TXT probe looks for the challenge token in the domain's DNS records, and the connect probe checks the name points at the deployment's connect target (the DNS name members are told to point their domain at).

Allocation writes funnel through one registry API under a single advisory lock, and every allocation — carving, claiming, placing — runs an evaluation ladder: validity, then occupancy, then name policy. (Releases, cancels, and the verification workflow's own probe, activation, and expiry writes are status-guarded single statements that run outside the lock and the ladder.) Refusals deliberately collapse into one uninformative member message — taken, reserved, blocked, and premium are indistinguishable from outside — with one exception: a budget refusal is verbose, because every fact in it is the caller's own history.

The claim lifecycle, in one pass: a member's external claim is born pending; the verification workflow moves it to active when a probe matches the challenge exactly; the workflow's final probe or the sweep moves an unproven claim to expired; a member (or the console compensating a failed saga, or an operator by force) moves it to canceled; and releasing an active claim moves it to released. Carved member claims skip verification and are born active.

Vocabulary reused: workspace, organization, and the System tenant (Identity, Organizations, and Workspaces), provider and slug (Providers and Integrations), boolean entitlement and primary pool assignment (Entitlements and Resource Pools) — the external-claim gate reads the external_domain_claims boolean on the workspace's pool. The legacy answerer is the pre-registry server that still knows which names it serves; the authorizer can forward a registry miss to it during the migration window, and it retires once every served name is a placement. A console rollback is the console itself cancelling a claim while compensating a failed saga — never the member's doing, which is why it is spared the abandonment ledger.

Where this lives

The registry is a core module with its own schema (domains) and migration stream.

Piece What it is Where
domains.claims Subtree ownership: kind, the status machine, the challenge token, the probe states (unchecked / missing / mismatch / match / error, per probe), and the three ledger columns (evidence_at, abandoned_at, system_canceled_at) internal/domains/migrations/00001_init.sql; 00002_claim_moderation.sql
domains.placements One exact name wired to one provider resource; hard-deleted, never tombstoned internal/domains/migrations/00001_init.sql
domains.name_rules Exact-label operator name policy applied to carving — reserved, blocked, and premium labels all refuse a member's carve (the kinds differ for operator bookkeeping, not member outcome); single-letter labels are premium by a built-in rule. Seed-and-SQL only, no UI internal/domains/migrations/00001_init.sql
The registry API The one write path: the advisory lock, the evaluation ladder, claim/carve/place/release, budgets, and the expiry entry points internal/domains/registry.go
Refusal policy The collapsed unavailable message, the budget-refusal exception, the built-in single-letter premium rule internal/domains/policy.go
State-machine SQL Every claim transition as a status-guarded UPDATE; ledger reads; subtree scans internal/domains/queries/claims.sql
internal/dnsname The dependency-free leaf package: normalize, reverse labels, validators internal/dnsname/dnsname.go
Verification workflow The per-claim Temporal loop: parallel TXT and connect probes with backoff, a check-now signal, the evidence latch, and a final probe at the deadline internal/workflows/domains/verify.go
Expiry sweep The 15-minute scheduled backstop (plus one unconditional boot pass) expiring pending claims whose verification workflow is gone internal/workflows/domains/sweep.go
The ask endpoint Unauthenticated exact-match authorization for the TLS proxy: 400 malformed, 200 for a servable placement (or a fallback-approved miss — see the ledger), 404 for everything else, 500 on lookup failure internal/server/domains.go
The authorizer The three-outcome decision — authorize, refuse locally, or forward a registry miss to the legacy answerer (fail-closed, 2-second timeout) internal/domains/authorizer.go
The external-claim gate Connect target configured AND the external_domain_claims boolean granted on the workspace's pool; injected into the registry internal/server/external_claim_gate.go
Member and operator surfaces Claim/cancel/release UI with DNS instructions; the operator moderation list with evidence and force release internal/server/member_domains.go; internal/server/operator_domains.go
FedWiki allocation saga Auto-carve and place-at-apex in one locked call on site creation; release on site deletion; registry-first compensation internal/integrations/fedwiki/workflows/domains.go
Boot reconciliation Three idempotent passes at every boot: ensure operator roots, release orphaned placements, adopt placement-less sites internal/integrations/fedwiki/reconcile_domains.go
Behavioral contracts The merged specs for claims, placements, the ask contract, and the FedWiki scenarios openspec/specs/domains-registry/spec.md; domain-authorization/spec.md; fedwiki-sites/spec.md

Invariants

An invariant is a rule the model guarantees everywhere; code that would break one is wrong even if it works locally. Tags: [db] — a schema constraint rejects violations; [db-code] — database-side code upholds it; [app] — only Go code upholds it.

  1. [db] The vocabularies are closed by CHECKs — kind (operator_root / member / external), status (pending / active / expired / canceled / released), probe states, rule kinds — and a claim has a parent exactly when its kind is member.
  2. [db] At most one live claim (pending or active) per exact root name; after any terminal state, re-claiming is a new row. The registry translates the unique violation into "lost the race".
  3. [db] At most one placement per name, deployment-wide and unconditional — the uniqueness is total because placements are hard-deleted, never tombstoned.
  4. [app] Live claims are disjoint subtrees — but as an allocation-time rule under the single advisory lock, not a standing schema constraint. One legal exception: configuring a new operator root tolerates and grandfathers pre-existing claims strictly inside it.
  5. [app] Only a placement authorizes serving; a claim authorizes nothing. The ask path looks up the exact name; an unservable placement refuses locally; only a true registry miss may be forwarded to the legacy answerer.
  6. [app] Release guards are opposite by object: a claim cannot be released while any placement remains (even by force), while releasing the last placement auto-releases only a carved member claim — an external claim survives being emptied, and an operator root is never releasable by anyone.
  7. [db-code] Nothing leaves a terminal state: every terminal write is a status-guarded UPDATE whose zero-rows result means the caller lost the race, and both expiry triggers (the workflow's final probe and the sweep) share the single expiry statement, so the ledger conditional exists in exactly one place.
  8. [db-code] The evidence latch is sticky and idempotent — once a probe has seen the challenge, that fact never unlatches — and exactly three statements write it: the probe recorder, the expiry statement, and the cancellation statement (expiry and cancel each carry a final verdict because neither has a later probe to correct it).
  9. [db-code] Blame lives in the ledger columns, never in the status: a member cancel first runs one last challenge lookup (a few quick attempts; if the lookup keeps failing, that counts as no evidence) and stamps abandonment only when neither that lookup nor any earlier probe saw the challenge; a console rollback stamps the system column and never abandonment; an operator force-cancel stamps abandonment unconditionally; release stamps nothing.
  10. [db-code] The ledger's two scopes are deliberately asymmetric. Counting is wide: an abandonment counts against the candidate's scope root — its last N labels (N is the configured ScopeLabels, default 2) — and everything beneath, so abandoning a.example.com and then trying b.example.com both fall under example.com's tally. Clearing is narrow: verifying a name clears only that name's own subtree, so verifying b.example.com does not launder the a.example.com abandonment. Reading the widths as symmetric would re-open exactly that laundering.
  11. [app] The external-claim gate is enforced inside the registry's claim call itself, before the lock — every entry point inherits it — while the registry still reads no entitlement tables (the gate is injected as a function).
  12. [app] Budgets are charged only for meaningful attempts: preflight failures (malformed, inside a root, already taken) cost nothing, and grandfathered adoptions (claims minted with no expiry by boot reconciliation to adopt pre-registry names) and console rollbacks never count toward initiation.
  13. [app] Carve checks bind every caller including the operator root's own workspace: evaluation takes an explicit intent (placing vs carving), and only placing gets the own-root shortcut — carving runs occupancy checks for everyone, and name policy for everyone except the boot-time grandfathering path, whose bypass of name policy (and name policy alone) is the spec's sole codified exception.

Dimensions

  • Claim vs placement — subtree ownership vs one served name. Only the placement serves.
  • Live vs active — live (pending or active) is the namespace predicate that holds the subtree against everyone, including the owner; active is the single status that permits placement and explicit release. A pending claim blocks even its own owner from re-claiming.
  • Root vs subtree — exact-name questions use the root column and its unique index; anything-inside questions use reversed labels and a prefix scan that deliberately excludes the root itself (an apex placement at a claim's own root is normal).
  • Count-scope vs clear-scope — the ledger counts wide and clears narrow (invariant 10). The single most damaging misreading in this model.
  • Status vs blame — "canceled" says nothing about fault; the ledger columns say who walked away, who was rolled back, and who had published evidence.
  • Verification vs evidence — two predicates over the same DNS answer: verification is exact token equality and gates activation; evidence is a looser prefix match and gates only the ledger. Verification implies evidence, never the reverse.
  • Servable vs allocated — servability is a flag on the placement, kept in sync from the provider resource's state (an archived FedWiki site marks its placement unservable). An unservable placement still occupies the name, still blocks release of its claim, and still refuses the ask locally; the flag controls only the yes/no to the proxy.
  • Name policy vs abuse policyname_rules and the single-letter rule govern only carving inside operator roots; the latch, ledger, and budgets govern only external claims. The two subsystems share no scope.
  • Place-intent vs carve-intent — for the root's own workspace these legitimately get different answers, and ownership alone cannot distinguish them, which is why evaluation takes the intent explicitly.