carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Managed custom-domain connections — platform-owned verification and cert status
2
3**Date:** 2026-08-27 · **Status:** approved (design approved in conversation
42026-08-27) · **Requested by:** the Tito Go team, via Paul
5
6**Closes:** #299 (managed custom-domain connections). **Folds in:** #287
7(attached custom domains are not published to edges files).
8
9**Builds on:** #151 (the global claim + `route_aliases` + the `domains`
10door), #82 / 2026-08-12-domain-attach-design.md (first-route attach, the
11DNS-pointing-as-evidence ruling this spec keeps), 2026-08-20-multi-region-ingress-design.md
12(the edges files, the elected issuer, `control/certs/` and the mirror),
13#300 / PR #301 (the per-host class mark this spec's alias mark follows),
14#291 / PR #297 (the cert backfill that makes `control/certs/` complete),
15#289 (the housekeeper gating this spec narrows), #207, #180 and #277 (the
16exact-only cert admission bound this spec must not widen).
17
18## 0. How this spec was arrived at
19
20Four adversarial review rounds (one per stage, then a whole-plan pass) ran
21against earlier revisions. Five blockers were found and folded in; the
22findings that changed the design are kept inline as rationale rather than
23stripped, because in each case the wrong answer is the one a reader would
24otherwise reach again — the `deploymentHosts`/`deploymentHost` confusion and
25the `Generation` staleness bound especially.
26
27Scope rulings (Paul, 2026-08-26/27):
28- Status + structured records + capability discovery. NOT TXT ownership
29 proof — claim authz stays first-writer-wins + DNS-pointing-as-evidence
30 (2026-08-12-domain-attach-design.md §2).
31- #287 folded in as stage 1.
32- Cert provisioning becomes proactive on the elected issuer.
33
34---
35
36## Stage 1 — make the inputs true
37
38### 1a. Aliases reach the edges files, with a class mark (#287)
39
40`edgesFilePass` (internal/host/edgesfile.go:101) folds `reg.ListAliases()`
41alongside `reg.List()`.
42
43Wire: `edgeHost` gains `Alias bool \`json:"alias"\`` — additive, ALWAYS
44emitted, the convention #300's `Platform` mark set (a file with no `alias`
45key is a pre-mark publisher, not a box publishing no aliases).
46
47Per alias: join to the primary via `RouteAlias.Host` against the `reg.List()`
48map already in hand; skip unless the primary is `Status == "ready"`; emit
49`edgeHost{Role:"home", Platform:false, Alias:true}`. `Platform:false` keeps
50customer domains out of wildcard-parent derivation (`parentInput`,
51homemap.go:343). Routes are written first; an alias never overwrites a route
52key on the same box.
53
54**The fold rule — the security fix.** `foldHomes` (homemap.go:446-465) today
55resolves a duplicate host by "first box in sorted order wins". New rule: **a
56non-alias entry beats an alias entry; among equals, sorted order still
57decides.**
58
59Why: `ReconcileAliases`' "a real route owns that host" refusal is per-box
60(`byHost` is this box's routes — aliases.go:41,66), and the attach door
61deliberately does not verify a route exists, because operator-minted routes
62have no instance record (apidomains.go:106-113). So an account can claim a
63hostname that is a real, UNCLAIMED route on another box. Today inert. Once
64both boxes publish `role:"home"`, sorted-order-wins hands the victim's
65hostname to the claimant on every other edge — under steering, most traffic.
66The vulnerable set is exactly the claimless routes: operator `carlos add`
67rows, `go.tito.io`, every route on a self-hosted box.
68
69**The mark must reach the reader, not just the writer.** The fold
70rule alone is not enough: `edgesHostView` (homemap.go:134-141) and
71`homeEntry` (homemap.go:143-149) carry only `Role`/`Platform`. Stage 1 must
72carry `alias` into both, plus an accessor enumerating fold entries —
73**all** of them, not only alias-marked ones, since 3a's corrected warm set is
74"not under the apps domain" rather than "alias". Without this accessor stage
753 is unimplementable.
76
77Note: the always-emit choice (never `omitempty`) matches what #300
78actually shipped — `Platform bool \`json:"platform"\`` with the comment "always
79emitted (never omitempty), so a file with no key at all is definitely a
80pre-mark publisher" (edgesfile.go:37-46). A review claimed #301 used
81`omitempty`; the code says otherwise, and the always-emit reasoning is what
82makes "old publisher" distinguishable from "nothing to declare".
83
84**The fold rule alone is NOT the whole fix.** It corrects what REMOTE
85edges answer. It does nothing on the claimant's own box: `ReconcileAliases`
86materializes the alias whenever the claim's primary exists locally and the
87victim's host is not a LOCAL route, and request routing consults the local
88upstream map before `Home()` — an exact local alias match wins regardless of
89the fold. So for a pre-existing or door-race claim, any traffic landing on the
90claimant's box serves the claimant's content over valid TLS (the mirror hands
91every box the victim's cert). That is reachable: an operator route outside the
92apps domain (the `go.tito.io` class — apps-domain hosts are door-denied) whose
93owner followed our own steering guidance (CNAME to `edge.<apps-domain>`)
94steers a share of traffic to every edge, the claimant's included.
95
96The authoritative refusal therefore belongs in **`ReconcileAliases`**, using
97the same shared reader: refuse to materialize an alias whose host any live box
98publishes as a non-alias `home` entry. That also retro-cleans pre-existing
99claims on the next pass, which neither the fold rule nor a door check can do.
100
101**Defence in depth at the door.** The attach door additionally refuses such a
102host at attach time. Best-effort (it races a concurrent mint), so it shortens
103the window but is not the fix. Its refusal must be byte-identical
104to the existing 409 "hostname unavailable" (apidomains.go:26-31), or it
105becomes an existence oracle for operator routes.
106
107**Roll order: readers before publishers — the INVERSE of #301's rule.**
108#301's mark failed CLOSED in an old reader (no mark → derive no parent), so
109publishers could lead. This one fails OPEN: an old reader sees no `alias` key
110and falls back to sorted-order-wins. Both halves are in one binary, so a
111box-by-box roll would have an early-rolled box publishing into un-rolled
112readers. Stage 1 therefore ships as TWO releases:
113
1141. **Reader release** — fold preference + `alias` carried into
115 `edgesHostView`/`homeEntry`. Inert while nothing publishes the mark.
1162. **Writer release** — alias publication, plus 1b–1e below.
117
118### 1b. #289 fixed the wrong thing — return the pass to every box
119
120The per-box divergence #289 saw was ONE INPUT (`cfg.EdgeIPs`, judged
121per-box), not the pass. Gating the whole pass silenced N-1 writers instead of
122fixing the input. Every section except the auto-purge writes only the box's
123own registry, derived deterministically from shared bucket state:
124
125- §1a account materialization (`reg.PutAccount`/`DeleteAccount`,
126 reconcile.go:127-137) — gated, non-housekeeper account tables are frozen,
127 so `carlos accounts list` on those boxes lies.
128- §1b route-account stamping (`reg.SetAccount`) — local.
129- §1c trashed-route removal (`reg.Delete`, reconcile.go:244-258) — gated,
130 **a trashed app's routes on non-housekeeper boxes keep serving** until the
131 purge cooloff deletes the records, against the code's own "Trashed apps
132 stop routing NOW" and its "the worse betrayal" rationale.
133- §2/§3 minting — mints into the LOCAL registry from `firstReadyUpstream`
134 over LOCAL routes. Per-box is CORRECT (it is how an everywhere-class app
135 gets its platform host on every box that serves it); housekeeper-only means
136 an app served only on non-housekeeper boxes never gets a platform domain.
137- §3 canary-route removal — local, deterministic from the bucket pointer.
138- §4/§5 DNS + cert stamps — local; the original Finding A.
139
140**Only §1d auto-purge is deployment-global** — it DESTROYS bucket objects,
141so it stays a housekeeper duty, lifted into `domains.PurgeTrashed`.
142
143### 1c. `pointing` judged against the deployment, not the box
144
145- `domains.Config.EdgeIPs []string` becomes `EdgeIPs func() []string`.
146 `domainsTickConfig` is read ONCE at arm (agent.go:1619) while the edge set
147 moves at runtime; a boot-frozen list is the bug being fixed.
148- **The parser needs a home both the agent and the console can
149 import.** 1c originally put the fold in `internal/host` — but the console
150 is a separate process (`cmd/console`) that does not import `internal/host`
151 at all, and stage 2's records/`edge_ips` need the same fold. Putting a
152 reader in the console would make it the FOURTH parser of the shape, which
153 is the rule 1c invokes against itself. So: extract one exported reader
154 (package `internal/edgesfile`, or exported from `internal/steering`) and
155 have `internal/host`, the console, and `internal/edge` share it.
156 Also: `internal/edge` needs the EIP set too (stage 3's DNS gate), and
157 host imports edge, never the reverse — a shared low-level package is the
158 only shape that serves all three.
159- Semantics: **UNION** the fold with `PLATFORM_EDGE_IPS`. The fold can only
160 widen, so a short or failed fold degrades to today's behaviour, never
161 below. (Contrast `internal/steering`, which fails CLOSED on a short fold
162 because a short fold there DELETES records; nothing is deleted here.)
163- **Liveness bound — NOT `Generation`.** An earlier revision proposed ignoring an
164 edges file whose `Generation` is older than a few publish cadences. That
165 cannot work: `Generation` is stamped ONLY when a write happens
166 (edgesfile.go:145), the change gate suppresses writes for unchanged content
167 (`bytes.Equal(gate, *last)`), and `seedEdgesFileGate` exists precisely so an
168 unchanged file is never republished across restarts. On a stable fleet every
169 HEALTHY box's `Generation` is arbitrarily old, so that bound would ignore
170 every healthy box and silently degrade the fold to env-only — defeating its
171 own purpose, in all three stages that share it.
172
173 Use box-snapshot freshness instead: `statusTick`'s `WriteBox` is
174 UNCONDITIONAL every tick (agent.go:1863) and `BoxStale` compares a moving
175 `GeneratedAt` (status/fleet.go:137-139) — a real liveness signal. A box
176 whose status snapshot is stale drops out of the EIP fold. This reuses the
177 loader and the staleness rule the console already applies
178 (apidomains.go:650-656), so there is one definition of "a box that still
179 counts", not two.
180
181 Why it matters: nothing deletes a retired box's `control/edges/<box>.json`,
182 so without a liveness bound a retired box's file keeps a DEAD customer
183 domain reading `pointing` — a lying green.
184- Guidance consistency: the console's `dnsTargets` (server.go:666) must read
185 the same fold, or the platform tells a customer to point at an address its
186 own checker will not credit.
187
188### 1d. Carry the stamp's age, and give claim-backed ROUTES a reading
189
190**This is a stage-2 blocker discovered here.** `Metrics.Aliases` is
191built exclusively from `Probes.Aliases` = `route_aliases` rows
192(snapshot.go:435-441), and `RouteVersions` carries `Version` only
193(snapshot.go:166). A **first-route claim** (`Claim.Route == ""`) mints a real
194registry ROUTE, not an alias — so it never gets a reading, and
195`carlos domains list` shows it `pending` forever. That is live today for
196`carlos deploy -kind static -host <site>`, a shipped headline flow.
197
198The fix is nearly free: `CollectBox(routes []registry.Route, ...)`
199(snapshot.go:376) already holds the routes, and `registry.Route` already
200carries the `DNSStatus`/`CertExpiresAt` columns §4/§5 stamp. Fold claim-
201backed route stamps into the same readings map in the loop already running,
202with `Route: r.Host` — which is exactly what `claims.go` means by "an empty
203`route` means this host is its own primary".
204
205Also:
206- Add `DNSCheckedAt int64 \`json:"dns_checked_at,omitempty"\`` to
207 `status.AliasReading` (additive). `registry` has `dns_checked_at`
208 (registry.go:198) and nothing downstream reads it.
209- Make the console fold prefer the FRESHEST reading. Today it is
210 last-box-wins by map iteration with no tie-break (apidomains.go:656-658),
211 so a frozen stamp can deterministically overwrite a fresh one.
212
213### 1e. `pending-dns` honesty
214
215`dnsStatus` returns `""` for ANY resolver error (reconcile.go:465-476) —
216NXDOMAIN and a box-side SERVFAIL are indistinguishable. Deriving
217`pending-dns` from that blames the customer's record for our outage, the
218failure the `unknown` state exists to prevent. Either add a third stamp value
219separating "authoritative no answer" from "lookup failed" (an additive
220registry + snapshot field, belonging in this release), or weaken the
221`pending-dns` description to what is actually derivable. **Recommend the
222third value** — it is small here and unpickable later.
223
224### 1f. Repins
225
226- `internal/host/domains_duty_test.go`, the `domainsDuty` doc comment, and
227 the arm log (agent.go:1632) pin "only the lease holder may reconcile" for
228 the WHOLE pass. Invert to the new boundary, or a future reader re-gates it
229 citing the old test.
230- New pins: an alias derives no wildcard parent; the fold prefers a route
231 over an alias; a not-ready primary publishes nothing; the stamping half
232 runs ungated on every box; a first-route claim gets a reading.
233
234### What stage 1 does NOT do
235
236It does not make a latency-steered custom domain work end to end. The
237client's TLS terminates at the NEAREST edge, before any backhaul; that edge
238serves the alias's cert only from the mirror, and the mirror holds one only
239after the issuer has ordered — which today needs a hello AT the issuer. Stage
2401 removes the structural blockers (challenge admission, challenge serving,
241backhaul, and the fact that an alias homed off the issuer cannot be ordered
242at all). The cert EXISTING is stage 3.
243
244---
245
246## Stage 2 — one status, structured records, capability
247
248**Rollout: stage 2's console roll TRAILS stage 3's fleet roll.** On
249the original order, every pointing-but-certless domain would report
250`issuing-cert` during the gap — naming platform activity that is
251structurally impossible until the warm ships — and would advertise a
252`failed` state nothing can produce. That is the same honesty test used to
253rename `verifying`, applied to my own state name and failed.
254
255### 2a. Vocabulary
256
257One `state` per domain, DERIVED on read, never stored:
258
259| state | means | derived from |
260|---|---|---|
261| `pending-dns` | no record found for this host yet | fresh reading, authoritative no-answer |
262| `misdirected` | resolves, but not to this deployment | DNSStatus "elsewhere" |
263| `issuing-cert` | points; no usable cert yet | pointing + no cert covering the host |
264| `live` | points AND a current cert covers it | pointing + cert |
265| `failed` | the platform tried and stopped | warm attempts exhausted (stage 3) |
266| `unknown` | nothing fresh reports on this host | no non-stale reading, or lookup failed |
267
268`misdirected` rather than the issue's `verifying`: `verifying` named titogo's
269ownership-proof phase, which Paul cut from scope, so the phase the word names
270does not exist here — and reusing it for "your record resolves elsewhere"
271would label a customer-side condition as platform activity. **This must be
272said on #299** so titogo does not build against the proposed vocabulary.
273
274Plus `reason` (stable machine slug) and `detail` (human sentence).
275
276**`status` becomes legacy-frozen.** Today `status:"live"` means "some
277non-stale box reported ANY reading" — it reads `live` while `dns_status` is
278`elsewhere` and no cert exists. Post-change the same row can read
279`status:"live", state:"misdirected"`. Keeping both is right (the wire rule;
280`status`'s values are frozen), but the trap needs fencing: (a) a test
281freezing `status`'s exact current semantics so nobody "fixes" it toward
282`state`; (b) every first-party surface — CLI STATUS column, console Settings,
283`carlos deploy`'s exit — moves to `state` in the same change; (c) the door
284comment and the issue reply mark `status` legacy.
285
286### 2b. Structured records
287
288`records: [...]` — what the customer must create. **Corrections:**
289- `alias_target` is emitted ONLY when steering is armed. `edge.<apps-domain>`
290 exists only where the steering converger maintains it (server.go:525-534);
291 on a non-steering deployment the draft's unconditional field named a
292 hostname with no records.
293- The array needs an **alternative-set marker**: with steering, the English
294 guidance offers CNAME *or* A records (view.go:163-165), and a flat array
295 cannot say "one of these two sets" — the records would contradict the
296 sentence beside them.
297- A **wildcard-enabled claim needs its `_acme-challenge` CNAME** in the array
298 (apidomains.go:409-410) — the platform already instructs it and stamps
299 `delegation_status`; omitting it makes "what you must create" incomplete
300 against our own surface.
301- **TTL 300**, with the reason stated: steering withdraws a dead edge in
302 ~90s, and a customer A-record TTL of 3600 nullifies that.
303- The English `guidance` sentence changes in LOCKSTEP (apex guidance never
304 mentions ALIAS today), or 2d's one-story rule breaks on day one.
305
306Source: the same shared fold as 1c, with the same Generation staleness bound.
307
308### 2c. Capability discovery — folded into the list answer
309
310**No new endpoint.** The original justification ("facts an app needs
311before it holds a claim") conflated *no claim* with *no app binding*: every
312caller is already app-bound, and `GET .../domains` answers fine with zero
313claims. Folding a capability block into the list answer's top level gives
314present-or-absent for free (an old console omits the keys; a console with no
315door at all 404s — the exact signal `carlos deploy` already uses,
316deploy.go:915-923), and needs no new role decision and no new limiter.
317
318```
319{"domains": [...],
320 "deployment": {"managed_domains": true, "apps_domain": "...",
321 "steering_host": "...", "edge_ips": [...],
322 "apex_alias_target": "...", "delegation_zone": "...",
323 "states": [...]}}
324```
325
326This also retires `domainsDoorMissing`'s 404-sniff (deploy.go:915-931) — our
327own version of titogo's `TITO_EDGE_ATTACH_URL` guessing.
328
329### 2d. Cert currency — an issuer-written index, not request-path reads
330
331**+The original 3d was wrong twice.** `control/certs/` entries
332are `<private key PEM><chain>` (certsync.go:741-744), so "read the cert from
333the bucket" means a Get and an x509 parse of a **key-bearing blob per host
334per list call** — strictly more expensive than the box stamp it replaces, on
335a door whose existing precedent caps *eight DNS lookups* per call
336(domainListCNAMELookupCap). And a custom domain served by a **delegated
337wildcard parent** has no per-host entry at all — its cert is
338`wildcard-<parent>.pem` — so presence-by-key would read `issuing-cert`
339forever for exactly the wildcard customers.
340
341Instead the console reads a small **index** — host → NotAfter — with no
342private keys in it.
343
344**The index is WRITTEN BY STAGE 3, read by stage 2.** An earlier revision described
345it inside stage 2, whose rollout entry is a console roll only — leaving the
346writer assigned to no release at all. It is issuer-side fleet code: it ships
347and rolls in stage 3 (§3h), and stage 2 merely consumes it. Stage 2 cannot
348land before stage 3 for this reason as well as the `issuing-cert` one.
349
350Storage shape differs from 3e's warm records, deliberately: NotAfter is
351**fully derivable** from the mirrored cache on every pass, so a single
352rebuilt-every-pass object is safe under last-writer-wins — a second issuer
353recomputes the same answer. Attempt counts are NOT derivable, which is why
3543e needs per-host CAS keys. Two shapes, two reasons, stated so neither is
355copied to the other.
356
357The index must replicate `hostCertExpiry`'s coverage logic
358(reconcile.go:744-755): per-host entry OR covering wildcard PEM — **and**
359check `<host>+rsa`, a gap `hostCertExpiry` itself has. Fix that gap at
360the source in stage 1, where the stamps are already being touched, so the
361box-stamped path and the index cannot disagree.
362
363### 2e. Scale and the poller contract
364
365**The list door cannot carry the poller stage 2 invites.**
366- `GET .../domains` sits behind `instanceGate` — `cliInstanceBurst` 60/min,
367 keyed on the token hash and **shared with the instance verbs**. A status
368 poller would eat the budget its signups and instance-creates run on.
369- Every list call is `ListClaims` = one List plus **one Get per claim in the
370 whole bucket**, filtered in memory afterwards (claims.go:175-199).
371- Required: rebuild the list on the #165 fast-path claims index (already
372 referenced at apidomains.go:436-439) **or** add a per-host
373 `GET .../domains/{host}` reading exactly one claim object; state a poll
374 cadence; move the poller's budget off the shared instance ceiling.
375
376### 2f. Detach
377
378The issue names "detach-failed-still-remove". After a 200 the claim is gone
379and the host leaves the list while edges still serve it until reconcile and
380the cert mirror catch up — a poller cannot tell "draining" from "never
381existed". Ruling: detach stays fire-and-forget and the answer says so; the
382alternative (a draining state) would require a tombstone the claim shape
383deliberately does not keep.
384
385### 2g. Surfaces
386
387`carlos domains list` gains a State column; console Settings shows the same
388state and records; `carlos deploy`'s DNS exit branches on `state` instead of
389`DNSStatus == "pointing"` (deploy.go:958-975), which currently cannot tell
390"your DNS is wrong" from "your DNS is right, the cert is coming". All read
391ONE derivation function.
392
393---
394
395## Stage 3 — the platform provisions the cert
396
397### 3a. The warm set — alias-derived
398
399**The draft cited the wrong symbol.** `deploymentHosts()` (plural,
400edge.go:738-756) excludes aliases by design (`!up.alias`) and its doc
401comment says "Not to be confused with deploymentHost (singular)". The
402singular (edge.go:831-833) is the set meant — but it is a PREDICATE with no
403enumerator. Built naively it would also warm **every canary host** (three
404labels under the apps domain, no wildcard parent covers them, DNS gate
405passes) — a per-host cert and CT-log entry for names that are today lazily
406issued and rarely visited. That, not custom domains, is where a first-run
407burst would come from.
408
409**An earlier revision said "aliases only". That is also wrong** — it drops
410first-route claims, which mint a real ROUTE, not an alias (1d). So
411`carlos deploy -kind static -host <site>`, the shipped headline flow this
412design itself cites, would never have its cert proactively ordered OR
413renewed, and stage 2's `failed` would be unreachable for that whole class.
414
415The right predicate is simpler than either: **exact hosts this deployment
416serves that are NOT under the apps domain.** A custom domain is a foreign
417domain by definition — the attach door refuses apps-domain hosts outright
418(apidomains.go:63-69) — so this one test covers aliases and first-route
419claims alike, and excludes canary hosts, platform hosts and everything a
420wildcard covers without needing to reason about label depth.
421
422- own box: keys of `e.current()` (routes AND aliases),
423- remote: entries from the home-map fold (1a's accessor, which must
424 therefore expose ALL entries, not only alias-marked ones),
425- minus `*.` rows (`Allowed` refuses them; a keys-of-map walk includes them),
426- minus anything under the apps domain,
427- minus names covered by a CONFIGURED wildcard parent
428 (`CARLOS_WILDCARD_DOMAINS`, e.g. `go.tito.io`) tested **by SHAPE** against
429 `currentParents()`, NOT `certFor`: `certFor` returns nil for a parent whose
430 cert is not yet obtained, so a new account's apps would all be warmed
431 per-host in the window before their wildcard lands.
432
433### 3b. The pass
434
435A per-host sibling of `wildcardManager` (wildcard.go:416-495), with three
436deliberate departures:
437
438- **No holding-context claim.** 3a originally copied "the order runs
439 under the holding's context" — impossible here: the trigger is
440 `m.GetCertificate`, which takes no context and builds its own
441 `context.Background()` with a 5-minute timeout (autocert.go:273-275). The
442 wildcard manager can honour the holding because it drives the ACME client
443 itself. Instead: re-check the gate BETWEEN orders (the backfill's
444 per-entry precedent, certsync.go:886) and accept a ≤5-minute overhang,
445 inside the double-holder envelope `issuerHostPolicy` already documents.
446 Consequence stated honestly: a finishing order's `cachePut` re-checks the
447 gate, so the cert lands local-only and the new issuer re-orders it —
448 bounded waste, not a hazard.
449- **The warm calls `GetCertificate` for every warm-set host EVERY
450 pass, unconditionally** — this is what owns renewal. autocert cannot renew
451 through a skip-if-fresh check: `m.cert` returns any unexpired cached cert,
452 so a cert 29 days from expiry is a cache hit and no order happens. Per-host
453 renewal is exclusively `domainRenewal` timers, which die on every fleet
454 roll and re-arm only via a `GetCertificate` that reaches `m.cert`. On a
455 steering deployment a custom domain's visitors may NEVER land on the
456 issuer, so after a roll nothing re-arms the timer and the cert expires at
457 day 90 silently. An unconditional call is one `cacheGet` plus `startRenew`
458 — cheap, and exactly the re-arm. Pin it with a test.
459- **No cache-load-before-gate step.** Unlike the wildcard manager, the warm
460 SERVES nothing (per-host serving is the mirror plus autocert), so a
461 non-issuer scanning caches is pure waste. It stands down at the gate.
462
463Retained: per-host hourly backoff on failure; **a harder backoff on an
464ACME `rateLimited` error** than on a validation failure, since hourly retries
465into a rate limit compound it; a per-pass order cap that returns a SHORT
466delay while capped work remains (the wildcard `pass()` precedent) so a
467backlog does not trickle out over days.
468
469**Arm only when `e.staticStore != nil`.** An agent-less `carlos edge`
470is ungated (`issuerGate` nil → yes), and would otherwise start proactively
471ordering on hobbyist boxes.
472
473### 3c. The DNS gate
474
475Resolve each host against the deployment edge set and order only when it
476points. LE's failed-validation limit is 5 per account per hostname per hour
477and the ACME account is shared per issuer — ordering for a half-finished DNS
478is how one customer burns everyone's budget.
479
480- **Require ALL resolved answers in the set**, not any: partial
481 pointing passes an any-match gate and then fails validation from LE's
482 vantage, burning exactly the budget the gate protects.
483- **The input does not exist in `internal/edge` yet.** 1c's fold lands
484 behind `internal/host`, and host imports edge, never the reverse. 1c's
485 shared reader package is what makes this reachable; the edge also already
486 parses `EIP` in `edgesFileView` (homemap.go:125-130), so the accessor can
487 live on the home map with 1c's Generation bound and env union.
488
489### 3d. The ECDSA trap — verified
490
491`m.GetCertificate(&tls.ClientHelloInfo{ServerName: host})` with an EMPTY
492hello: `supportsECDSA` returns **false** (nil `SignatureSchemes` and nil
493`SupportedCurves` skip their checks; the `CipherSuites` loop finds nothing)
494→ `certKey{isRSA:true}` → the warm mints an **RSA** cert under `<host>+rsa`
495while browsers ask for `<host>`. The order is spent, nothing is satisfied,
496and the first visitor still triggers a second issuance.
497
498Fix: advertise an ECDSA suite in the synthetic hello. Independently confirmed
499against x/crypto v0.52.0 (go.mod:17). Everything else checks out: `ServerName`
500must be non-empty and contain a dot; idna/lowercasing applied internally; nil
501`SupportedProtos` → `wantsTokenCert` false; no `Conn` dereference. The repo
502already ports `supportsECDSA` as `helloSupportsECDSA` (certsync.go:1285-1327)
503— pin PARITY between the two copies, plus the non-`+rsa`-key assertion.
504
505### 3e. Where warm outcomes go
506
507**Per-host CAS keys, not one file.** `control/cert-warm/<host>.json`,
508written with the `putCert` CAS idiom. A whole-file map has no per-host
509last-writer-wins safety: two transient holders, or a failover, overwrite each
510other wholesale, and a takeover issuer starting from empty in-memory state
511erases every attempt count. Each record carries a **written-at stamp**, and
512the console applies an age bound — a wedged issuer must not read as "warm
513quiet". The writer prunes records for detached hosts.
514
515Verified: the fleet door is an **allowlist** (`aclRead`,
516storedoor.go:383-397), so any new `control/` key is denied by default — but
517`TestDoorNeverServesCertMaterial` (storedoor_test.go:515-567) must gain the
518new keys. Cert writes bump no serial (certsync.go:25-28) — correct here too.
519The bucket-writer audit and the ingress spec's writer table need a new row
520ruled "issuer" before the write exists.
521
522### 3f. Removal — expiry-bounded, never inference-driven
523
524Nothing ever deletes `control/certs/<host>` for a detached domain (`Delete`
525fires only for spent tokens). The backfill will not re-publish it
526(`ownsCertName` refuses), but the object lingers, the mirror serves it to
527matching SNI until expiry, and the expired object then lingers forever.
528
529**The sweep's predicate must be EXPIRY, not detachment.** A sweep
530keyed "no claim exists for this host" would delete every non-claim-backed
531cert in the deployment — every platform host, every wildcard PEM, the account
532keys. Worse, registry lag or a stale edges read could take down a live host's
533certs fleet-wide via the mirror. This repo's own rule is that inference never
534destroys owner property (reconcile.go §1c).
535
536So: delete only entries whose LEAF IS ALREADY EXPIRED plus a margin. That
537also dissolves the "mirror serves it until expiry" concern by construction,
538and makes a wrong deletion harmless — the entry was already dead. Never
539"detached implies delete". Prune cert-warm records on the same pass, on the
540same terms. Issuer-side; #243's token-litter ruling is the precedent for
541assigning it, not for its predicate.
542
543### 3h. The cert index (read by stage 2 §2d)
544
545On every warm pass the issuer rebuilds `control/cert-index.json`:
546host → NotAfter, for every host in the warm set, resolved through
547`hostCertExpiry`'s coverage rules (per-host entry, `<host>+rsa`, or a
548covering wildcard PEM). Whole-file and last-writer-wins is safe here because
549the content is fully derived — a second issuer recomputes the same answer
550(contrast 3e, whose attempt counts are not derivable and need per-host CAS).
551Carries a written-at stamp; the console applies an age bound so a wedged
552issuer does not read as "everything current". Added to
553`TestDoorNeverServesCertMaterial`'s pinned list and to the bucket-writer
554audit's table, ruled "issuer", before the write exists.
555
556### 3g. Observability
557
558Failures are 3e. Add the success line (one per obtained host) and a per-pass
559summary, matching the wildcard manager's logging discipline.
560
561---
562
563## The shared edges-file reader — the plan's load-bearing artifact
564
565Six things stand on it: 1a's `ReconcileAliases` refusal and door check, 1c's
566EIP fold, 2b's records, 2c's `edge_ips`, and 3c's order gate. Earlier revisions gave the
567riskiest new surface the least design, so it gets its own contract:
568
569- **Where:** one small exported package (`internal/edgesfile`) that owns the
570 wire struct. `internal/host` (writer), `internal/edge`, the console and
571 `internal/domains` all import it. `internal/steering` keeps its own
572 deliberately narrow view (it reads no `Hosts` — steering.go:57-60) and is
573 named here as a knowing exception rather than left unlisted, per the
574 consumer-list rule.
575- **What it answers:** the live edge-IP set (union with `PLATFORM_EDGE_IPS`),
576 and per-host "is this published as a non-alias home by a live box".
577- **Liveness:** the box-snapshot join above, never `Generation`.
578- **Failure:** degrades to env-only, never to empty — nothing here deletes,
579 so widening is always the safe direction.
580- **Cost:** cached with a refresh cadence on the console side; the list door
581 must not pay a per-request fold.
582
583## Rollout order
584
5851. **Stage 1 reader** — fold preference + `alias`/full-entry accessor in the
586 reader types. Inert no-op roll.
5872. **Stage 1 writer** — split into independently green PRs rather than
588 one: (i) alias publication + fold + `ReconcileAliases` refusal + door
589 check; (ii) the #289 inversion + `PurgeTrashed`; (iii) the shared reader +
590 live EIP set + `dnsTargets`; (iv) route readings + stamp age +
591 resolver-error value + the `+rsa` fix. Fleet roll.
5923. **Stage 3** — the warm and the cert index. Fleet roll.
5934. **Stage 2** — the API, CLI and console. Console roll plus a CLI release
594 (tap/apt/website, per the standing sync rule), LAST, so no state the
595 deployment cannot yet produce is ever advertised.
596
597Stage 1 ships real value alone (a live security gap, a live regression, a
598live false-negative). Stages 3 and 2 together are what make `live` mean what
599#299 asks it to mean.
600
601## Live checks owed before the first armed roll
602
603- Count `control/certs/` keys against the enumerated warm set on the flagship
604 issuer — confirm the first pass orders nothing for the existing estate.
605- Confirm which box currently holds the housekeeper lease on flagship and
606 tito, to size 1b's live blast radius.
607
608---
609
610## Addendum — what was built, and where it diverged (2026-08-27)
611
612The spec above was written against `main` at `da9a7ee`. By the time it was
613implemented `main` had moved to `4235e9f` and had independently built most of
614stage 1 (#287's alias publication, #303's per-box stamps, the
615`Reconcile`/`StampHealth` split). Stage 1 was therefore re-derived as a
616salvage — see `.superpowers/sdd/salvage-onto-main/progress.md` — and stages 2
617and 3 were built against the newer base. The divergences below are decisions,
618not omissions, and each names what it would cost to be wrong.
619
620### The vocabulary gained `checking` and never gained `failed`
621
622`checking` (attached; no edge has reported yet) was split out of `unknown`.
623Both mean "no verdict", but they are opposite news: one is the ordinary first
624few minutes after an attach, the other is a fault on our side. Collapsed,
625every successful attach opened with the word "unknown" — which an e2e test
626caught by asserting on the freshly-attached row.
627
628`failed` is absent. It would mean "the platform tried and stopped", which
629requires §3e's per-host warm-outcome records — not built (below). The spec's
630own honesty test, the one that renamed `verifying`, applies to it directly:
631a state nothing can produce must not be advertised. A test now pins that the
632advertised set and the producible set are exactly equal, so `failed` returns
633with §3e and not before.
634
635### §2d's cert index (§3h) was NOT built; stamps are folded instead
636
637The spec argued for an issuer-written host→NotAfter index because reading
638`control/certs/` on the request path means a Get and an x509 parse of a
639key-bearing blob per host, and because a wildcard-covered domain has no
640per-host entry at all.
641
642Both objections stand — against reading the BUCKET. They do not apply to the
643path actually taken: the boxes already stamp cert expiry into the registry,
644`status.LoadBoxes` already carries those stamps to the console, and the
645console already folds them across boxes (the freshest-stamp fold). Stage 2
646reads that fold. The wildcard case is covered because `hostCertExpiry`
647already checks the covering wildcard PEM — and the `<host>+rsa` gap the spec
648flagged was fixed at the source, so the box-stamped path and any future index
649cannot disagree.
650
651Cost if wrong: the fold is bounded by box staleness, so a deployment whose
652boxes all go stale reads `checking` rather than `live`. That is the correct
653failure direction, and §3h remains available if the fold proves too coarse.
654
655Not building it also avoided a new bucket object, a new writer-table row, a
656new `aclRead` allowlist entry and an update to
657`TestDoorNeverServesCertMaterial` — none of which buy anything the existing
658stamps do not already carry.
659
660### §3e and §3f were follow-ups; §3e is now built
661
662§3e (per-host `control/cert-warm/<host>.json` CAS records) and §3f (the
663expiry-bounded cert sweep) shipped after the first change, not with it. The
664warm worked without either: it ordered, it renewed, it backed off per host,
665and it logged. What was missing was durable observability of warm outcomes
666across a failover, and the pruning of certs for long-detached hosts.
667
668**§3e is built (2026-08-29).** `internal/certwarm` owns the wire shape for
669both sides, the issuer writes a record through the same CAS loop `putCert`
670uses (extracted as `putCAS`), and the console folds it into `domainState`.
671Three decisions the spec did not settle, decided here:
672
673- **A record exists only while a host is FAILING.** Success deletes it. That
674 makes absence the healthy case, so the console's fold costs one object per
675 BROKEN hostname rather than one per domain — which is what makes it safe
676 to add to a list door that already pays one Get per claim (#173). The
677 per-host door reads one key instead of listing, preserving the property
678 #328 exists for.
679- **Freshness is judged against `next_attempt_at`, not `written_at`.** A
680 rate-limited host is deliberately not retried for twelve hours, and a bound
681 on write time alone would call that healthy silence stale every time. The
682 accepted limit: an issuer that wedges immediately after writing a
683 rate-limited record is believed for up to fourteen hours. Closing that
684 needs a per-pass heartbeat object — a second key and a second writer-table
685 row for a sharper edge on an already rare case.
686- **`failed` fires at three consecutive failures, or immediately on a rate
687 limit.** Three is about three hours of trying. The rate-limit exception is
688 because the retry is twelve hours away, and a member watching a "usually a
689 minute or two" message for half a day is worse served than one told
690 plainly what is happening.
691
692`failed` never overrides a customer-side condition (`misdirected`,
693`pending-dns`) and never overrides a live certificate: it is only ever the
694alternative to `issuing-cert`. The vocabulary honesty test now produces it
695from an input the platform can actually produce, which is the same bar every
696other state clears.
697
698**§3f is built (2026-08-29)**, in `internal/edge/certsweep.go`, and it took
699#243's spent-token litter with it — one issuer-side pass over
700`control/certs/`, two predicates, because the two kinds of litter are not the
701same kind of thing:
702
703- **Spent challenge tokens** (#243) go on AGE alone. A validation's whole
704 life is seconds and autocert's order context is five minutes, so the
705 one-hour TTL is generous by two orders of magnitude — deliberately, since
706 deleting a token out from under a live validation burns the order.
707- **Certificates** go only on their own leaf's expiry, plus a month. §3f's
708 ruling is unchanged and is the reason the file opens with it: a sweep keyed
709 "no claim exists for this host" would delete every platform host's cert,
710 every wildcard PEM and the account keys, and a registry lag could take a
711 live host down fleet-wide through the mirror.
712
713Three bounds the spec did not name:
714
715- **A parse floor.** Nothing younger than sixty days is read at all. autocert
716 renews at ~30 days remaining on a 90-day certificate, so a healthy entry's
717 object stamp never gets that old — which makes the steady-state cost one
718 listing and zero reads, instead of the per-object N+1 this would otherwise
719 be. An entry that IS that old is one nothing is renewing, which is exactly
720 the population worth reading.
721- **Anything unreadable is left alone.** A sweep that deletes what it cannot
722 parse is a sweep that deletes tomorrow's format.
723- **Fifty deletions per pass.** A bug in the predicate removes at most fifty
724 objects before somebody reads the log.
725
726The gate check moved above the empty-warm-set return in `pass()`: token
727litter and dead certs exist in a deployment with no custom domains at all,
728and the old ordering would have meant such a deployment never swept. A
729non-issuer still sweeps nothing.
730
731### The shared reader is a READER, not the wire-shape owner
732
733`internal/edgesfile` reads the deployment-wide edges picture. It does not own
734the writer's struct: `internal/host` had developed that file substantially
735(wildcard parents, withheld-alias throttling, EIP-reuse-on-blank), and
736re-plumbing it onto a shared type would have fought a file under active
737development for no gain to #299. The two shapes are joined by a test instead,
738which also preserves the additive-wire property — the writer can add a field
739and roll before every reader understands it.
740
741### Rollout
742
743The spec required stage 3's fleet roll to precede stage 2's console roll, so
744that `issuing-cert` never names activity that cannot happen. Both ship in one
745branch, so the constraint applies to the ROLL and not the merge: **roll the
746fleet before the consoles.** Rolled the other way, every pointing-but-certless
747domain reports `issuing-cert` while nothing is issuing.
748
749### Still owed
750
751- §2e's poller contract: `GET .../domains` remains behind `instanceGate`'s
752 shared 60/min budget and still costs one Get per claim in the bucket. A
753 status poller must not be pointed at it until the #165 claims index or a
754 per-host door lands. **This must be said on #299** — it is the one part of
755 the issue's ask this change does not make safe.
756- `deploy.go`'s `domainsDoorMissing` 404-sniff could now be retired in favour
757 of the capability block. Left in place: it still works, and retiring it is
758 not free of risk against older consoles.
759- The console Settings page still renders the pre-state wording. The CLI and
760 the deploy exit moved; the page has not.
761