spec + ledger: the managed custom-domains design, and the salvage record
The spec is the design authority for #299 and travels with the work. The Stage 1 plan is deliberately NOT restored: main implemented most of stage 1 independently while that plan was executing, so it argues from a base that no longer exists. The ledger records what was salvaged, what was dropped because main already had it (and did it better), and the three rulings where the two implementations actually disagreed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 file changed,
+606
−0
docs/superpowers/specs/2026-08-27-managed-custom-domains-design.md+606 −0
CI passed — run details
diff --git a/docs/superpowers/specs/2026-08-27-managed-custom-domains-design.md b/docs/superpowers/specs/2026-08-27-managed-custom-domains-design.md| new file mode 100644 |
| index 000000000..b2b0f78f0 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-27-managed-custom-domains-design.md |
| @@ -0,0 +1,606 @@ |
| +# Managed custom-domain connections — platform-owned verification and cert status |
| + |
| +**Date:** 2026-08-27 · **Status:** approved (design approved in conversation |
| +2026-08-27) · **Requested by:** the Tito Go team, via Paul |
| + |
| +**Closes:** #299 (managed custom-domain connections). **Folds in:** #287 |
| +(attached custom domains are not published to edges files). |
| + |
| +**Builds on:** #151 (the global claim + `route_aliases` + the `domains` |
| +door), #82 / 2026-08-12-domain-attach-design.md (first-route attach, the |
| +DNS-pointing-as-evidence ruling this spec keeps), 2026-08-20-multi-region-ingress-design.md |
| +(the edges files, the elected issuer, `control/certs/` and the mirror), |
| +#300 / PR #301 (the per-host class mark this spec's alias mark follows), |
| +#291 / PR #297 (the cert backfill that makes `control/certs/` complete), |
| +#289 (the housekeeper gating this spec narrows), #207, #180 and #277 (the |
| +exact-only cert admission bound this spec must not widen). |
| + |
| +## 0. How this spec was arrived at |
| + |
| +Four adversarial review rounds (one per stage, then a whole-plan pass) ran |
| +against earlier revisions. Five blockers were found and folded in; the |
| +findings that changed the design are kept inline as rationale rather than |
| +stripped, because in each case the wrong answer is the one a reader would |
| +otherwise reach again — the `deploymentHosts`/`deploymentHost` confusion and |
| +the `Generation` staleness bound especially. |
| + |
| +Scope rulings (Paul, 2026-08-26/27): |
| +- Status + structured records + capability discovery. NOT TXT ownership |
| + proof — claim authz stays first-writer-wins + DNS-pointing-as-evidence |
| + (2026-08-12-domain-attach-design.md §2). |
| +- #287 folded in as stage 1. |
| +- Cert provisioning becomes proactive on the elected issuer. |
| + |
| +--- |
| + |
| +## Stage 1 — make the inputs true |
| + |
| +### 1a. Aliases reach the edges files, with a class mark (#287) |
| + |
| +`edgesFilePass` (internal/host/edgesfile.go:101) folds `reg.ListAliases()` |
| +alongside `reg.List()`. |
| + |
| +Wire: `edgeHost` gains `Alias bool \`json:"alias"\`` — additive, ALWAYS |
| +emitted, the convention #300's `Platform` mark set (a file with no `alias` |
| +key is a pre-mark publisher, not a box publishing no aliases). |
| + |
| +Per alias: join to the primary via `RouteAlias.Host` against the `reg.List()` |
| +map already in hand; skip unless the primary is `Status == "ready"`; emit |
| +`edgeHost{Role:"home", Platform:false, Alias:true}`. `Platform:false` keeps |
| +customer domains out of wildcard-parent derivation (`parentInput`, |
| +homemap.go:343). Routes are written first; an alias never overwrites a route |
| +key on the same box. |
| + |
| +**The fold rule — the security fix.** `foldHomes` (homemap.go:446-465) today |
| +resolves a duplicate host by "first box in sorted order wins". New rule: **a |
| +non-alias entry beats an alias entry; among equals, sorted order still |
| +decides.** |
| + |
| +Why: `ReconcileAliases`' "a real route owns that host" refusal is per-box |
| +(`byHost` is this box's routes — aliases.go:41,66), and the attach door |
| +deliberately does not verify a route exists, because operator-minted routes |
| +have no instance record (apidomains.go:106-113). So an account can claim a |
| +hostname that is a real, UNCLAIMED route on another box. Today inert. Once |
| +both boxes publish `role:"home"`, sorted-order-wins hands the victim's |
| +hostname to the claimant on every other edge — under steering, most traffic. |
| +The vulnerable set is exactly the claimless routes: operator `carlos add` |
| +rows, `go.tito.io`, every route on a self-hosted box. |
| + |
| +**The mark must reach the reader, not just the writer.** The fold |
| +rule alone is not enough: `edgesHostView` (homemap.go:134-141) and |
| +`homeEntry` (homemap.go:143-149) carry only `Role`/`Platform`. Stage 1 must |
| +carry `alias` into both, plus an accessor enumerating fold entries — |
| +**all** of them, not only alias-marked ones, since 3a's corrected warm set is |
| +"not under the apps domain" rather than "alias". Without this accessor stage |
| +3 is unimplementable. |
| + |
| +Note: the always-emit choice (never `omitempty`) matches what #300 |
| +actually shipped — `Platform bool \`json:"platform"\`` with the comment "always |
| +emitted (never omitempty), so a file with no key at all is definitely a |
| +pre-mark publisher" (edgesfile.go:37-46). A review claimed #301 used |
| +`omitempty`; the code says otherwise, and the always-emit reasoning is what |
| +makes "old publisher" distinguishable from "nothing to declare". |
| + |
| +**The fold rule alone is NOT the whole fix.** It corrects what REMOTE |
| +edges answer. It does nothing on the claimant's own box: `ReconcileAliases` |
| +materializes the alias whenever the claim's primary exists locally and the |
| +victim's host is not a LOCAL route, and request routing consults the local |
| +upstream map before `Home()` — an exact local alias match wins regardless of |
| +the fold. So for a pre-existing or door-race claim, any traffic landing on the |
| +claimant's box serves the claimant's content over valid TLS (the mirror hands |
| +every box the victim's cert). That is reachable: an operator route outside the |
| +apps domain (the `go.tito.io` class — apps-domain hosts are door-denied) whose |
| +owner followed our own steering guidance (CNAME to `edge.<apps-domain>`) |
| +steers a share of traffic to every edge, the claimant's included. |
| + |
| +The authoritative refusal therefore belongs in **`ReconcileAliases`**, using |
| +the same shared reader: refuse to materialize an alias whose host any live box |
| +publishes as a non-alias `home` entry. That also retro-cleans pre-existing |
| +claims on the next pass, which neither the fold rule nor a door check can do. |
| + |
| +**Defence in depth at the door.** The attach door additionally refuses such a |
| +host at attach time. Best-effort (it races a concurrent mint), so it shortens |
| +the window but is not the fix. Its refusal must be byte-identical |
| +to the existing 409 "hostname unavailable" (apidomains.go:26-31), or it |
| +becomes an existence oracle for operator routes. |
| + |
| +**Roll order: readers before publishers — the INVERSE of #301's rule.** |
| +#301's mark failed CLOSED in an old reader (no mark → derive no parent), so |
| +publishers could lead. This one fails OPEN: an old reader sees no `alias` key |
| +and falls back to sorted-order-wins. Both halves are in one binary, so a |
| +box-by-box roll would have an early-rolled box publishing into un-rolled |
| +readers. Stage 1 therefore ships as TWO releases: |
| + |
| +1. **Reader release** — fold preference + `alias` carried into |
| + `edgesHostView`/`homeEntry`. Inert while nothing publishes the mark. |
| +2. **Writer release** — alias publication, plus 1b–1e below. |
| + |
| +### 1b. #289 fixed the wrong thing — return the pass to every box |
| + |
| +The per-box divergence #289 saw was ONE INPUT (`cfg.EdgeIPs`, judged |
| +per-box), not the pass. Gating the whole pass silenced N-1 writers instead of |
| +fixing the input. Every section except the auto-purge writes only the box's |
| +own registry, derived deterministically from shared bucket state: |
| + |
| +- §1a account materialization (`reg.PutAccount`/`DeleteAccount`, |
| + reconcile.go:127-137) — gated, non-housekeeper account tables are frozen, |
| + so `carlos accounts list` on those boxes lies. |
| +- §1b route-account stamping (`reg.SetAccount`) — local. |
| +- §1c trashed-route removal (`reg.Delete`, reconcile.go:244-258) — gated, |
| + **a trashed app's routes on non-housekeeper boxes keep serving** until the |
| + purge cooloff deletes the records, against the code's own "Trashed apps |
| + stop routing NOW" and its "the worse betrayal" rationale. |
| +- §2/§3 minting — mints into the LOCAL registry from `firstReadyUpstream` |
| + over LOCAL routes. Per-box is CORRECT (it is how an everywhere-class app |
| + gets its platform host on every box that serves it); housekeeper-only means |
| + an app served only on non-housekeeper boxes never gets a platform domain. |
| +- §3 canary-route removal — local, deterministic from the bucket pointer. |
| +- §4/§5 DNS + cert stamps — local; the original Finding A. |
| + |
| +**Only §1d auto-purge is deployment-global** — it DESTROYS bucket objects, |
| +so it stays a housekeeper duty, lifted into `domains.PurgeTrashed`. |
| + |
| +### 1c. `pointing` judged against the deployment, not the box |
| + |
| +- `domains.Config.EdgeIPs []string` becomes `EdgeIPs func() []string`. |
| + `domainsTickConfig` is read ONCE at arm (agent.go:1619) while the edge set |
| + moves at runtime; a boot-frozen list is the bug being fixed. |
| +- **The parser needs a home both the agent and the console can |
| + import.** 1c originally put the fold in `internal/host` — but the console |
| + is a separate process (`cmd/console`) that does not import `internal/host` |
| + at all, and stage 2's records/`edge_ips` need the same fold. Putting a |
| + reader in the console would make it the FOURTH parser of the shape, which |
| + is the rule 1c invokes against itself. So: extract one exported reader |
| + (package `internal/edgesfile`, or exported from `internal/steering`) and |
| + have `internal/host`, the console, and `internal/edge` share it. |
| + Also: `internal/edge` needs the EIP set too (stage 3's DNS gate), and |
| + host imports edge, never the reverse — a shared low-level package is the |
| + only shape that serves all three. |
| +- Semantics: **UNION** the fold with `PLATFORM_EDGE_IPS`. The fold can only |
| + widen, so a short or failed fold degrades to today's behaviour, never |
| + below. (Contrast `internal/steering`, which fails CLOSED on a short fold |
| + because a short fold there DELETES records; nothing is deleted here.) |
| +- **Liveness bound — NOT `Generation`.** An earlier revision proposed ignoring an |
| + edges file whose `Generation` is older than a few publish cadences. That |
| + cannot work: `Generation` is stamped ONLY when a write happens |
| + (edgesfile.go:145), the change gate suppresses writes for unchanged content |
| + (`bytes.Equal(gate, *last)`), and `seedEdgesFileGate` exists precisely so an |
| + unchanged file is never republished across restarts. On a stable fleet every |
| + HEALTHY box's `Generation` is arbitrarily old, so that bound would ignore |
| + every healthy box and silently degrade the fold to env-only — defeating its |
| + own purpose, in all three stages that share it. |
| + |
| + Use box-snapshot freshness instead: `statusTick`'s `WriteBox` is |
| + UNCONDITIONAL every tick (agent.go:1863) and `BoxStale` compares a moving |
| + `GeneratedAt` (status/fleet.go:137-139) — a real liveness signal. A box |
| + whose status snapshot is stale drops out of the EIP fold. This reuses the |
| + loader and the staleness rule the console already applies |
| + (apidomains.go:650-656), so there is one definition of "a box that still |
| + counts", not two. |
| + |
| + Why it matters: nothing deletes a retired box's `control/edges/<box>.json`, |
| + so without a liveness bound a retired box's file keeps a DEAD customer |
| + domain reading `pointing` — a lying green. |
| +- Guidance consistency: the console's `dnsTargets` (server.go:666) must read |
| + the same fold, or the platform tells a customer to point at an address its |
| + own checker will not credit. |
| + |
| +### 1d. Carry the stamp's age, and give claim-backed ROUTES a reading |
| + |
| +**This is a stage-2 blocker discovered here.** `Metrics.Aliases` is |
| +built exclusively from `Probes.Aliases` = `route_aliases` rows |
| +(snapshot.go:435-441), and `RouteVersions` carries `Version` only |
| +(snapshot.go:166). A **first-route claim** (`Claim.Route == ""`) mints a real |
| +registry ROUTE, not an alias — so it never gets a reading, and |
| +`carlos domains list` shows it `pending` forever. That is live today for |
| +`carlos deploy -kind static -host <site>`, a shipped headline flow. |
| + |
| +The fix is nearly free: `CollectBox(routes []registry.Route, ...)` |
| +(snapshot.go:376) already holds the routes, and `registry.Route` already |
| +carries the `DNSStatus`/`CertExpiresAt` columns §4/§5 stamp. Fold claim- |
| +backed route stamps into the same readings map in the loop already running, |
| +with `Route: r.Host` — which is exactly what `claims.go` means by "an empty |
| +`route` means this host is its own primary". |
| + |
| +Also: |
| +- Add `DNSCheckedAt int64 \`json:"dns_checked_at,omitempty"\`` to |
| + `status.AliasReading` (additive). `registry` has `dns_checked_at` |
| + (registry.go:198) and nothing downstream reads it. |
| +- Make the console fold prefer the FRESHEST reading. Today it is |
| + last-box-wins by map iteration with no tie-break (apidomains.go:656-658), |
| + so a frozen stamp can deterministically overwrite a fresh one. |
| + |
| +### 1e. `pending-dns` honesty |
| + |
| +`dnsStatus` returns `""` for ANY resolver error (reconcile.go:465-476) — |
| +NXDOMAIN and a box-side SERVFAIL are indistinguishable. Deriving |
| +`pending-dns` from that blames the customer's record for our outage, the |
| +failure the `unknown` state exists to prevent. Either add a third stamp value |
| +separating "authoritative no answer" from "lookup failed" (an additive |
| +registry + snapshot field, belonging in this release), or weaken the |
| +`pending-dns` description to what is actually derivable. **Recommend the |
| +third value** — it is small here and unpickable later. |
| + |
| +### 1f. Repins |
| + |
| +- `internal/host/domains_duty_test.go`, the `domainsDuty` doc comment, and |
| + the arm log (agent.go:1632) pin "only the lease holder may reconcile" for |
| + the WHOLE pass. Invert to the new boundary, or a future reader re-gates it |
| + citing the old test. |
| +- New pins: an alias derives no wildcard parent; the fold prefers a route |
| + over an alias; a not-ready primary publishes nothing; the stamping half |
| + runs ungated on every box; a first-route claim gets a reading. |
| + |
| +### What stage 1 does NOT do |
| + |
| +It does not make a latency-steered custom domain work end to end. The |
| +client's TLS terminates at the NEAREST edge, before any backhaul; that edge |
| +serves the alias's cert only from the mirror, and the mirror holds one only |
| +after the issuer has ordered — which today needs a hello AT the issuer. Stage |
| +1 removes the structural blockers (challenge admission, challenge serving, |
| +backhaul, and the fact that an alias homed off the issuer cannot be ordered |
| +at all). The cert EXISTING is stage 3. |
| + |
| +--- |
| + |
| +## Stage 2 — one status, structured records, capability |
| + |
| +**Rollout: stage 2's console roll TRAILS stage 3's fleet roll.** On |
| +the original order, every pointing-but-certless domain would report |
| +`issuing-cert` during the gap — naming platform activity that is |
| +structurally impossible until the warm ships — and would advertise a |
| +`failed` state nothing can produce. That is the same honesty test used to |
| +rename `verifying`, applied to my own state name and failed. |
| + |
| +### 2a. Vocabulary |
| + |
| +One `state` per domain, DERIVED on read, never stored: |
| + |
| +| state | means | derived from | |
| +|---|---|---| |
| +| `pending-dns` | no record found for this host yet | fresh reading, authoritative no-answer | |
| +| `misdirected` | resolves, but not to this deployment | DNSStatus "elsewhere" | |
| +| `issuing-cert` | points; no usable cert yet | pointing + no cert covering the host | |
| +| `live` | points AND a current cert covers it | pointing + cert | |
| +| `failed` | the platform tried and stopped | warm attempts exhausted (stage 3) | |
| +| `unknown` | nothing fresh reports on this host | no non-stale reading, or lookup failed | |
| + |
| +`misdirected` rather than the issue's `verifying`: `verifying` named titogo's |
| +ownership-proof phase, which Paul cut from scope, so the phase the word names |
| +does not exist here — and reusing it for "your record resolves elsewhere" |
| +would label a customer-side condition as platform activity. **This must be |
| +said on #299** so titogo does not build against the proposed vocabulary. |
| + |
| +Plus `reason` (stable machine slug) and `detail` (human sentence). |
| + |
| +**`status` becomes legacy-frozen.** Today `status:"live"` means "some |
| +non-stale box reported ANY reading" — it reads `live` while `dns_status` is |
| +`elsewhere` and no cert exists. Post-change the same row can read |
| +`status:"live", state:"misdirected"`. Keeping both is right (the wire rule; |
| +`status`'s values are frozen), but the trap needs fencing: (a) a test |
| +freezing `status`'s exact current semantics so nobody "fixes" it toward |
| +`state`; (b) every first-party surface — CLI STATUS column, console Settings, |
| +`carlos deploy`'s exit — moves to `state` in the same change; (c) the door |
| +comment and the issue reply mark `status` legacy. |
| + |
| +### 2b. Structured records |
| + |
| +`records: [...]` — what the customer must create. **Corrections:** |
| +- `alias_target` is emitted ONLY when steering is armed. `edge.<apps-domain>` |
| + exists only where the steering converger maintains it (server.go:525-534); |
| + on a non-steering deployment the draft's unconditional field named a |
| + hostname with no records. |
| +- The array needs an **alternative-set marker**: with steering, the English |
| + guidance offers CNAME *or* A records (view.go:163-165), and a flat array |
| + cannot say "one of these two sets" — the records would contradict the |
| + sentence beside them. |
| +- A **wildcard-enabled claim needs its `_acme-challenge` CNAME** in the array |
| + (apidomains.go:409-410) — the platform already instructs it and stamps |
| + `delegation_status`; omitting it makes "what you must create" incomplete |
| + against our own surface. |
| +- **TTL 300**, with the reason stated: steering withdraws a dead edge in |
| + ~90s, and a customer A-record TTL of 3600 nullifies that. |
| +- The English `guidance` sentence changes in LOCKSTEP (apex guidance never |
| + mentions ALIAS today), or 2d's one-story rule breaks on day one. |
| + |
| +Source: the same shared fold as 1c, with the same Generation staleness bound. |
| + |
| +### 2c. Capability discovery — folded into the list answer |
| + |
| +**No new endpoint.** The original justification ("facts an app needs |
| +before it holds a claim") conflated *no claim* with *no app binding*: every |
| +caller is already app-bound, and `GET .../domains` answers fine with zero |
| +claims. Folding a capability block into the list answer's top level gives |
| +present-or-absent for free (an old console omits the keys; a console with no |
| +door at all 404s — the exact signal `carlos deploy` already uses, |
| +deploy.go:915-923), and needs no new role decision and no new limiter. |
| + |
| +``` |
| +{"domains": [...], |
| + "deployment": {"managed_domains": true, "apps_domain": "...", |
| + "steering_host": "...", "edge_ips": [...], |
| + "apex_alias_target": "...", "delegation_zone": "...", |
| + "states": [...]}} |
| +``` |
| + |
| +This also retires `domainsDoorMissing`'s 404-sniff (deploy.go:915-931) — our |
| +own version of titogo's `TITO_EDGE_ATTACH_URL` guessing. |
| + |
| +### 2d. Cert currency — an issuer-written index, not request-path reads |
| + |
| +**+The original 3d was wrong twice.** `control/certs/` entries |
| +are `<private key PEM><chain>` (certsync.go:741-744), so "read the cert from |
| +the bucket" means a Get and an x509 parse of a **key-bearing blob per host |
| +per list call** — strictly more expensive than the box stamp it replaces, on |
| +a door whose existing precedent caps *eight DNS lookups* per call |
| +(domainListCNAMELookupCap). And a custom domain served by a **delegated |
| +wildcard parent** has no per-host entry at all — its cert is |
| +`wildcard-<parent>.pem` — so presence-by-key would read `issuing-cert` |
| +forever for exactly the wildcard customers. |
| + |
| +Instead the console reads a small **index** — host → NotAfter — with no |
| +private keys in it. |
| + |
| +**The index is WRITTEN BY STAGE 3, read by stage 2.** An earlier revision described |
| +it inside stage 2, whose rollout entry is a console roll only — leaving the |
| +writer assigned to no release at all. It is issuer-side fleet code: it ships |
| +and rolls in stage 3 (§3h), and stage 2 merely consumes it. Stage 2 cannot |
| +land before stage 3 for this reason as well as the `issuing-cert` one. |
| + |
| +Storage shape differs from 3e's warm records, deliberately: NotAfter is |
| +**fully derivable** from the mirrored cache on every pass, so a single |
| +rebuilt-every-pass object is safe under last-writer-wins — a second issuer |
| +recomputes the same answer. Attempt counts are NOT derivable, which is why |
| +3e needs per-host CAS keys. Two shapes, two reasons, stated so neither is |
| +copied to the other. |
| + |
| +The index must replicate `hostCertExpiry`'s coverage logic |
| +(reconcile.go:744-755): per-host entry OR covering wildcard PEM — **and** |
| +check `<host>+rsa`, a gap `hostCertExpiry` itself has. Fix that gap at |
| +the source in stage 1, where the stamps are already being touched, so the |
| +box-stamped path and the index cannot disagree. |
| + |
| +### 2e. Scale and the poller contract |
| + |
| +**The list door cannot carry the poller stage 2 invites.** |
| +- `GET .../domains` sits behind `instanceGate` — `cliInstanceBurst` 60/min, |
| + keyed on the token hash and **shared with the instance verbs**. A status |
| + poller would eat the budget its signups and instance-creates run on. |
| +- Every list call is `ListClaims` = one List plus **one Get per claim in the |
| + whole bucket**, filtered in memory afterwards (claims.go:175-199). |
| +- Required: rebuild the list on the #165 fast-path claims index (already |
| + referenced at apidomains.go:436-439) **or** add a per-host |
| + `GET .../domains/{host}` reading exactly one claim object; state a poll |
| + cadence; move the poller's budget off the shared instance ceiling. |
| + |
| +### 2f. Detach |
| + |
| +The issue names "detach-failed-still-remove". After a 200 the claim is gone |
| +and the host leaves the list while edges still serve it until reconcile and |
| +the cert mirror catch up — a poller cannot tell "draining" from "never |
| +existed". Ruling: detach stays fire-and-forget and the answer says so; the |
| +alternative (a draining state) would require a tombstone the claim shape |
| +deliberately does not keep. |
| + |
| +### 2g. Surfaces |
| + |
| +`carlos domains list` gains a State column; console Settings shows the same |
| +state and records; `carlos deploy`'s DNS exit branches on `state` instead of |
| +`DNSStatus == "pointing"` (deploy.go:958-975), which currently cannot tell |
| +"your DNS is wrong" from "your DNS is right, the cert is coming". All read |
| +ONE derivation function. |
| + |
| +--- |
| + |
| +## Stage 3 — the platform provisions the cert |
| + |
| +### 3a. The warm set — alias-derived |
| + |
| +**The draft cited the wrong symbol.** `deploymentHosts()` (plural, |
| +edge.go:738-756) excludes aliases by design (`!up.alias`) and its doc |
| +comment says "Not to be confused with deploymentHost (singular)". The |
| +singular (edge.go:831-833) is the set meant — but it is a PREDICATE with no |
| +enumerator. Built naively it would also warm **every canary host** (three |
| +labels under the apps domain, no wildcard parent covers them, DNS gate |
| +passes) — a per-host cert and CT-log entry for names that are today lazily |
| +issued and rarely visited. That, not custom domains, is where a first-run |
| +burst would come from. |
| + |
| +**An earlier revision said "aliases only". That is also wrong** — it drops |
| +first-route claims, which mint a real ROUTE, not an alias (1d). So |
| +`carlos deploy -kind static -host <site>`, the shipped headline flow this |
| +design itself cites, would never have its cert proactively ordered OR |
| +renewed, and stage 2's `failed` would be unreachable for that whole class. |
| + |
| +The right predicate is simpler than either: **exact hosts this deployment |
| +serves that are NOT under the apps domain.** A custom domain is a foreign |
| +domain by definition — the attach door refuses apps-domain hosts outright |
| +(apidomains.go:63-69) — so this one test covers aliases and first-route |
| +claims alike, and excludes canary hosts, platform hosts and everything a |
| +wildcard covers without needing to reason about label depth. |
| + |
| +- own box: keys of `e.current()` (routes AND aliases), |
| +- remote: entries from the home-map fold (1a's accessor, which must |
| + therefore expose ALL entries, not only alias-marked ones), |
| +- minus `*.` rows (`Allowed` refuses them; a keys-of-map walk includes them), |
| +- minus anything under the apps domain, |
| +- minus names covered by a CONFIGURED wildcard parent |
| + (`CARLOS_WILDCARD_DOMAINS`, e.g. `go.tito.io`) tested **by SHAPE** against |
| + `currentParents()`, NOT `certFor`: `certFor` returns nil for a parent whose |
| + cert is not yet obtained, so a new account's apps would all be warmed |
| + per-host in the window before their wildcard lands. |
| + |
| +### 3b. The pass |
| + |
| +A per-host sibling of `wildcardManager` (wildcard.go:416-495), with three |
| +deliberate departures: |
| + |
| +- **No holding-context claim.** 3a originally copied "the order runs |
| + under the holding's context" — impossible here: the trigger is |
| + `m.GetCertificate`, which takes no context and builds its own |
| + `context.Background()` with a 5-minute timeout (autocert.go:273-275). The |
| + wildcard manager can honour the holding because it drives the ACME client |
| + itself. Instead: re-check the gate BETWEEN orders (the backfill's |
| + per-entry precedent, certsync.go:886) and accept a ≤5-minute overhang, |
| + inside the double-holder envelope `issuerHostPolicy` already documents. |
| + Consequence stated honestly: a finishing order's `cachePut` re-checks the |
| + gate, so the cert lands local-only and the new issuer re-orders it — |
| + bounded waste, not a hazard. |
| +- **The warm calls `GetCertificate` for every warm-set host EVERY |
| + pass, unconditionally** — this is what owns renewal. autocert cannot renew |
| + through a skip-if-fresh check: `m.cert` returns any unexpired cached cert, |
| + so a cert 29 days from expiry is a cache hit and no order happens. Per-host |
| + renewal is exclusively `domainRenewal` timers, which die on every fleet |
| + roll and re-arm only via a `GetCertificate` that reaches `m.cert`. On a |
| + steering deployment a custom domain's visitors may NEVER land on the |
| + issuer, so after a roll nothing re-arms the timer and the cert expires at |
| + day 90 silently. An unconditional call is one `cacheGet` plus `startRenew` |
| + — cheap, and exactly the re-arm. Pin it with a test. |
| +- **No cache-load-before-gate step.** Unlike the wildcard manager, the warm |
| + SERVES nothing (per-host serving is the mirror plus autocert), so a |
| + non-issuer scanning caches is pure waste. It stands down at the gate. |
| + |
| +Retained: per-host hourly backoff on failure; **a harder backoff on an |
| +ACME `rateLimited` error** than on a validation failure, since hourly retries |
| +into a rate limit compound it; a per-pass order cap that returns a SHORT |
| +delay while capped work remains (the wildcard `pass()` precedent) so a |
| +backlog does not trickle out over days. |
| + |
| +**Arm only when `e.staticStore != nil`.** An agent-less `carlos edge` |
| +is ungated (`issuerGate` nil → yes), and would otherwise start proactively |
| +ordering on hobbyist boxes. |
| + |
| +### 3c. The DNS gate |
| + |
| +Resolve each host against the deployment edge set and order only when it |
| +points. LE's failed-validation limit is 5 per account per hostname per hour |
| +and the ACME account is shared per issuer — ordering for a half-finished DNS |
| +is how one customer burns everyone's budget. |
| + |
| +- **Require ALL resolved answers in the set**, not any: partial |
| + pointing passes an any-match gate and then fails validation from LE's |
| + vantage, burning exactly the budget the gate protects. |
| +- **The input does not exist in `internal/edge` yet.** 1c's fold lands |
| + behind `internal/host`, and host imports edge, never the reverse. 1c's |
| + shared reader package is what makes this reachable; the edge also already |
| + parses `EIP` in `edgesFileView` (homemap.go:125-130), so the accessor can |
| + live on the home map with 1c's Generation bound and env union. |
| + |
| +### 3d. The ECDSA trap — verified |
| + |
| +`m.GetCertificate(&tls.ClientHelloInfo{ServerName: host})` with an EMPTY |
| +hello: `supportsECDSA` returns **false** (nil `SignatureSchemes` and nil |
| +`SupportedCurves` skip their checks; the `CipherSuites` loop finds nothing) |
| +→ `certKey{isRSA:true}` → the warm mints an **RSA** cert under `<host>+rsa` |
| +while browsers ask for `<host>`. The order is spent, nothing is satisfied, |
| +and the first visitor still triggers a second issuance. |
| + |
| +Fix: advertise an ECDSA suite in the synthetic hello. Independently confirmed |
| +against x/crypto v0.52.0 (go.mod:17). Everything else checks out: `ServerName` |
| +must be non-empty and contain a dot; idna/lowercasing applied internally; nil |
| +`SupportedProtos` → `wantsTokenCert` false; no `Conn` dereference. The repo |
| +already ports `supportsECDSA` as `helloSupportsECDSA` (certsync.go:1285-1327) |
| +— pin PARITY between the two copies, plus the non-`+rsa`-key assertion. |
| + |
| +### 3e. Where warm outcomes go |
| + |
| +**Per-host CAS keys, not one file.** `control/cert-warm/<host>.json`, |
| +written with the `putCert` CAS idiom. A whole-file map has no per-host |
| +last-writer-wins safety: two transient holders, or a failover, overwrite each |
| +other wholesale, and a takeover issuer starting from empty in-memory state |
| +erases every attempt count. Each record carries a **written-at stamp**, and |
| +the console applies an age bound — a wedged issuer must not read as "warm |
| +quiet". The writer prunes records for detached hosts. |
| + |
| +Verified: the fleet door is an **allowlist** (`aclRead`, |
| +storedoor.go:383-397), so any new `control/` key is denied by default — but |
| +`TestDoorNeverServesCertMaterial` (storedoor_test.go:515-567) must gain the |
| +new keys. Cert writes bump no serial (certsync.go:25-28) — correct here too. |
| +The bucket-writer audit and the ingress spec's writer table need a new row |
| +ruled "issuer" before the write exists. |
| + |
| +### 3f. Removal — expiry-bounded, never inference-driven |
| + |
| +Nothing ever deletes `control/certs/<host>` for a detached domain (`Delete` |
| +fires only for spent tokens). The backfill will not re-publish it |
| +(`ownsCertName` refuses), but the object lingers, the mirror serves it to |
| +matching SNI until expiry, and the expired object then lingers forever. |
| + |
| +**The sweep's predicate must be EXPIRY, not detachment.** A sweep |
| +keyed "no claim exists for this host" would delete every non-claim-backed |
| +cert in the deployment — every platform host, every wildcard PEM, the account |
| +keys. Worse, registry lag or a stale edges read could take down a live host's |
| +certs fleet-wide via the mirror. This repo's own rule is that inference never |
| +destroys owner property (reconcile.go §1c). |
| + |
| +So: delete only entries whose LEAF IS ALREADY EXPIRED plus a margin. That |
| +also dissolves the "mirror serves it until expiry" concern by construction, |
| +and makes a wrong deletion harmless — the entry was already dead. Never |
| +"detached implies delete". Prune cert-warm records on the same pass, on the |
| +same terms. Issuer-side; #243's token-litter ruling is the precedent for |
| +assigning it, not for its predicate. |
| + |
| +### 3h. The cert index (read by stage 2 §2d) |
| + |
| +On every warm pass the issuer rebuilds `control/cert-index.json`: |
| +host → NotAfter, for every host in the warm set, resolved through |
| +`hostCertExpiry`'s coverage rules (per-host entry, `<host>+rsa`, or a |
| +covering wildcard PEM). Whole-file and last-writer-wins is safe here because |
| +the content is fully derived — a second issuer recomputes the same answer |
| +(contrast 3e, whose attempt counts are not derivable and need per-host CAS). |
| +Carries a written-at stamp; the console applies an age bound so a wedged |
| +issuer does not read as "everything current". Added to |
| +`TestDoorNeverServesCertMaterial`'s pinned list and to the bucket-writer |
| +audit's table, ruled "issuer", before the write exists. |
| + |
| +### 3g. Observability |
| + |
| +Failures are 3e. Add the success line (one per obtained host) and a per-pass |
| +summary, matching the wildcard manager's logging discipline. |
| + |
| +--- |
| + |
| +## The shared edges-file reader — the plan's load-bearing artifact |
| + |
| +Six things stand on it: 1a's `ReconcileAliases` refusal and door check, 1c's |
| +EIP fold, 2b's records, 2c's `edge_ips`, and 3c's order gate. Earlier revisions gave the |
| +riskiest new surface the least design, so it gets its own contract: |
| + |
| +- **Where:** one small exported package (`internal/edgesfile`) that owns the |
| + wire struct. `internal/host` (writer), `internal/edge`, the console and |
| + `internal/domains` all import it. `internal/steering` keeps its own |
| + deliberately narrow view (it reads no `Hosts` — steering.go:57-60) and is |
| + named here as a knowing exception rather than left unlisted, per the |
| + consumer-list rule. |
| +- **What it answers:** the live edge-IP set (union with `PLATFORM_EDGE_IPS`), |
| + and per-host "is this published as a non-alias home by a live box". |
| +- **Liveness:** the box-snapshot join above, never `Generation`. |
| +- **Failure:** degrades to env-only, never to empty — nothing here deletes, |
| + so widening is always the safe direction. |
| +- **Cost:** cached with a refresh cadence on the console side; the list door |
| + must not pay a per-request fold. |
| + |
| +## Rollout order |
| + |
| +1. **Stage 1 reader** — fold preference + `alias`/full-entry accessor in the |
| + reader types. Inert no-op roll. |
| +2. **Stage 1 writer** — split into independently green PRs rather than |
| + one: (i) alias publication + fold + `ReconcileAliases` refusal + door |
| + check; (ii) the #289 inversion + `PurgeTrashed`; (iii) the shared reader + |
| + live EIP set + `dnsTargets`; (iv) route readings + stamp age + |
| + resolver-error value + the `+rsa` fix. Fleet roll. |
| +3. **Stage 3** — the warm and the cert index. Fleet roll. |
| +4. **Stage 2** — the API, CLI and console. Console roll plus a CLI release |
| + (tap/apt/website, per the standing sync rule), LAST, so no state the |
| + deployment cannot yet produce is ever advertised. |
| + |
| +Stage 1 ships real value alone (a live security gap, a live regression, a |
| +live false-negative). Stages 3 and 2 together are what make `live` mean what |
| +#299 asks it to mean. |
| + |
| +## Live checks owed before the first armed roll |
| + |
| +- Count `control/certs/` keys against the enumerated warm set on the flagship |
| + issuer — confirm the first pass orders nothing for the existing estate. |
| +- Confirm which box currently holds the housekeeper lease on flagship and |
| + tito, to size 1b's live blast radius. |