# 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.

---

## 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`, not `written_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.
- **`failed` fires 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 .../domains` remains behind `instanceGate`'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`'s `domainsDoorMissing` 404-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.
