carlos / platform Public
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:
- Reader release — fold preference +
aliascarried intoedgesHostView/homeEntry. Inert while nothing publishes the mark. - 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, socarlos accounts liston 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
firstReadyUpstreamover 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 []stringbecomesEdgeIPs func() []string.domainsTickConfigis 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 importinternal/hostat all, and stage 2's records/edge_ipsneed 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 (packageinternal/edgesfile, or exported frominternal/steering) and haveinternal/host, the console, andinternal/edgeshare it. Also:internal/edgeneeds 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. (Contrastinternal/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 whoseGenerationis older than a few publish cadences. That cannot work:Generationis stamped ONLY when a write happens (edgesfile.go:145), the change gate suppresses writes for unchanged content (bytes.Equal(gate, *last)), andseedEdgesFileGateexists precisely so an unchanged file is never republished across restarts. On a stable fleet every HEALTHY box'sGenerationis 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:"dnscheckedat,omitempty"\`tostatus.AliasReading(additive).registryhasdnscheckedat` (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, thedomainsDutydoc 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_targetis 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-challengeCNAME in the array (apidomains.go:409-410) — the platform already instructs it and stampsdelegation_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
guidancesentence 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 .../domainssits behindinstanceGate—cliInstanceBurst60/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 (Allowedrefuses 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 againstcurrentParents(), NOTcertFor:certForreturns 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 owncontext.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 envelopeissuerHostPolicyalready documents. Consequence stated honestly: a finishing order'scachePutre-checks the gate, so the cert lands local-only and the new issuer re-orders it — bounded waste, not a hazard. - The warm calls
GetCertificatefor every warm-set host EVERY pass, unconditionally — this is what owns renewal. autocert cannot renew through a skip-if-fresh check:m.certreturns any unexpired cached cert, so a cert 29 days from expiry is a cache hit and no order happens. Per-host renewal is exclusivelydomainRenewaltimers, which die on every fleet roll and re-arm only via aGetCertificatethat reachesm.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 onecacheGetplusstartRenew— 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/edgeyet. 1c's fold lands behindinternal/host, and host imports edge, never the reverse. 1c's shared reader package is what makes this reachable; the edge also already parsesEIPinedgesFileView(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 andinternal/domainsall import it.internal/steeringkeeps its own deliberately narrow view (it reads noHosts— 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
- Stage 1 reader — fold preference +
alias/full-entry accessor in the reader types. Inert no-op roll. - Stage 1 writer — split into independently green PRs rather than one: (i) alias publication + fold +
ReconcileAliasesrefusal + door check; (ii) the #289 inversion +PurgeTrashed; (iii) the shared reader + live EIP set +dnsTargets; (iv) route readings + stamp age + resolver-error value + the+rsafix. Fleet roll. - Stage 3 — the warm and the cert index. Fleet roll.
- 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.
---
Addendum — what was built, and where it diverged (2026-08-27)
The spec above was written against main at da9a7ee. By the time it was implemented main had moved to 4235e9f and had independently built most of stage 1 (#287's alias publication, #303's per-box stamps, the Reconcile/StampHealth split). Stage 1 was therefore re-derived as a salvage — see .superpowers/sdd/salvage-onto-main/progress.md — and stages 2 and 3 were built against the newer base. The divergences below are decisions, not omissions, and each names what it would cost to be wrong.
The vocabulary gained checking and never gained failed
checking (attached; no edge has reported yet) was split out of unknown. Both mean "no verdict", but they are opposite news: one is the ordinary first few minutes after an attach, the other is a fault on our side. Collapsed, every successful attach opened with the word "unknown" — which an e2e test caught by asserting on the freshly-attached row.
failed is absent. It would mean "the platform tried and stopped", which requires §3e's per-host warm-outcome records — not built (below). The spec's own honesty test, the one that renamed verifying, applies to it directly: a state nothing can produce must not be advertised. A test now pins that the advertised set and the producible set are exactly equal, so failed returns with §3e and not before.
§2d's cert index (§3h) was NOT built; stamps are folded instead
The spec argued for an issuer-written host→NotAfter index because reading control/certs/ on the request path means a Get and an x509 parse of a key-bearing blob per host, and because a wildcard-covered domain has no per-host entry at all.
Both objections stand — against reading the BUCKET. They do not apply to the path actually taken: the boxes already stamp cert expiry into the registry, status.LoadBoxes already carries those stamps to the console, and the console already folds them across boxes (the freshest-stamp fold). Stage 2 reads that fold. The wildcard case is covered because hostCertExpiry already checks the covering wildcard PEM — and the <host>+rsa gap the spec flagged was fixed at the source, so the box-stamped path and any future index cannot disagree.
Cost if wrong: the fold is bounded by box staleness, so a deployment whose boxes all go stale reads checking rather than live. That is the correct failure direction, and §3h remains available if the fold proves too coarse.
Not building it also avoided a new bucket object, a new writer-table row, a new aclRead allowlist entry and an update to TestDoorNeverServesCertMaterial — none of which buy anything the existing stamps do not already carry.
§3e and §3f were follow-ups; §3e is now built
§3e (per-host control/cert-warm/<host>.json CAS records) and §3f (the expiry-bounded cert sweep) shipped after the first change, not with it. The warm worked without either: it ordered, it renewed, it backed off per host, and it logged. What was missing was durable observability of warm outcomes across a failover, and the pruning of certs for long-detached hosts.
§3e is built (2026-08-29). internal/certwarm owns the wire shape for both sides, the issuer writes a record through the same CAS loop putCert uses (extracted as putCAS), and the console folds it into domainState. Three decisions the spec did not settle, decided here:
- A record exists only while a host is FAILING. Success deletes it. That makes absence the healthy case, so the console's fold costs one object per BROKEN hostname rather than one per domain — which is what makes it safe to add to a list door that already pays one Get per claim (#173). The per-host door reads one key instead of listing, preserving the property #328 exists for.
- Freshness is judged against
next_attempt_at, notwritten_at. A rate-limited host is deliberately not retried for twelve hours, and a bound on write time alone would call that healthy silence stale every time. The accepted limit: an issuer that wedges immediately after writing a rate-limited record is believed for up to fourteen hours. Closing that needs a per-pass heartbeat object — a second key and a second writer-table row for a sharper edge on an already rare case. failedfires at three consecutive failures, or immediately on a rate limit. Three is about three hours of trying. The rate-limit exception is because the retry is twelve hours away, and a member watching a "usually a minute or two" message for half a day is worse served than one told plainly what is happening.
failed never overrides a customer-side condition (misdirected, pending-dns) and never overrides a live certificate: it is only ever the alternative to issuing-cert. The vocabulary honesty test now produces it from an input the platform can actually produce, which is the same bar every other state clears.
§3f is built (2026-08-29), in internal/edge/certsweep.go, and it took #243's spent-token litter with it — one issuer-side pass over control/certs/, two predicates, because the two kinds of litter are not the same kind of thing:
- Spent challenge tokens (#243) go on AGE alone. A validation's whole life is seconds and autocert's order context is five minutes, so the one-hour TTL is generous by two orders of magnitude — deliberately, since deleting a token out from under a live validation burns the order.
- Certificates go only on their own leaf's expiry, plus a month. §3f's ruling is unchanged and is the reason the file opens with it: a sweep keyed "no claim exists for this host" would delete every platform host's cert, every wildcard PEM and the account keys, and a registry lag could take a live host down fleet-wide through the mirror.
Three bounds the spec did not name:
- A parse floor. Nothing younger than sixty days is read at all. autocert renews at ~30 days remaining on a 90-day certificate, so a healthy entry's object stamp never gets that old — which makes the steady-state cost one listing and zero reads, instead of the per-object N+1 this would otherwise be. An entry that IS that old is one nothing is renewing, which is exactly the population worth reading.
- Anything unreadable is left alone. A sweep that deletes what it cannot parse is a sweep that deletes tomorrow's format.
- Fifty deletions per pass. A bug in the predicate removes at most fifty objects before somebody reads the log.
The gate check moved above the empty-warm-set return in pass(): token litter and dead certs exist in a deployment with no custom domains at all, and the old ordering would have meant such a deployment never swept. A non-issuer still sweeps nothing.
The shared reader is a READER, not the wire-shape owner
internal/edgesfile reads the deployment-wide edges picture. It does not own the writer's struct: internal/host had developed that file substantially (wildcard parents, withheld-alias throttling, EIP-reuse-on-blank), and re-plumbing it onto a shared type would have fought a file under active development for no gain to #299. The two shapes are joined by a test instead, which also preserves the additive-wire property — the writer can add a field and roll before every reader understands it.
Rollout
The spec required stage 3's fleet roll to precede stage 2's console roll, so that issuing-cert never names activity that cannot happen. Both ship in one branch, so the constraint applies to the ROLL and not the merge: roll the fleet before the consoles. Rolled the other way, every pointing-but-certless domain reports issuing-cert while nothing is issuing.
Still owed
- §2e's poller contract:
GET .../domainsremains behindinstanceGate's shared 60/min budget and still costs one Get per claim in the bucket. A status poller must not be pointed at it until the #165 claims index or a per-host door lands. This must be said on #299 — it is the one part of the issue's ask this change does not make safe. deploy.go'sdomainsDoorMissing404-sniff could now be retired in favour of the capability block. Left in place: it still works, and retiring it is not free of risk against older consoles.- The console Settings page still renders the pre-state wording. The CLI and the deploy exit moved; the page has not.