carlos / platform Public

docs: steering rollout notes

Task 8 of the multi-region-steering plan (spec Part 4): marks every
Part 4 row implemented-dormant with commit refs, corrects the
housekeeper-not-console DNS-writer claim and the stale "HTTPS + SNI"
health-probe prose against what actually shipped (dc30c28's
records-not-registry fix, Task 7's wildcard-parent probe mechanism),
fixes the Fingerprint/shapeMismatch overclaim, and adds the
Route53-only and v1-pool-rail notes.

Plan rollout notes: 8-step arming checklist consuming plan 2's stage-5
gates, mechanism notes (refused-pass-reports-green, no-escape-hatch
shrink guard, records-not-registry, dormancy wording), plan-2 constraint
reconciliation (pool narrowing honored, role over-claim resolved by
construction, T7 egress ruling flagged as still open), and a full
deferred-minor triage across every task (fix-wave-candidate vs
recorded, with reasons).

Docs-only; no Go files touched. Full gate (build/vet/test/darwin) green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 250c4e1ed95352f92b50530ef60585ea568f6ba9 parent 460ac9b
2 files changed, +464 −26
  • docs/superpowers/plans/2026-08-23-multi-region-steering.md +287 −7
  • docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md +177 −19
diff --git a/docs/superpowers/plans/2026-08-23-multi-region-steering.md b/docs/superpowers/plans/2026-08-23-multi-region-steering.md
index 999bbf135..dd5fee0c7 100644
--- a/docs/superpowers/plans/2026-08-23-multi-region-steering.md
+++ b/docs/superpowers/plans/2026-08-23-multi-region-steering.md
@@ -42,7 +42,10 @@ zones are Route53 — dated spec note in Task 8).
**AMENDED (controller, 2026-08-23, on Task 2's review):** only
`SnapshotFingerprint` rides routeCols — pin it. `Fingerprint` is a
hand-curated reload-relevance set with NO steering consumer on the
- edge (the converger is a host duty reading the registry directly):
+ edge (the converger is a host duty reading bucket records directly —
+ **corrected 2026-08-22, T8**: Task 6's review ruled the converger
+ reads `provision.ListAllRecords`, never the registry; see that task's
+ carry below and the spec's Part 4 amendment):
steering stays OUT of `Fingerprint`, recorded as a deliberate
exclusion in the unit-column idiom. Likewise `shapeMismatch` does NOT
compare steering — it is a record-owned column (Channel/Environment
@@ -288,7 +291,10 @@ func desiredState(routes []registry.Route, edges []edgesFileView, appsDomain str
**Interfaces:**
- Consumes: Tasks 4-5; `CARLOS_STEERING_ZONE_ID` (unset → the loop arms,
logs once "steering: zone unset — dormant", duty returns nil
- immediately); the registry for routes; the store for edges files.
+ immediately); the store for edges files and (**corrected 2026-08-22,
+ T8, per the review ruling below**) for routes too — the registry is
+ NOT consulted; `provision.ListAllRecords` walks bucket records
+ instead, because routes are not uniformly provisioned across boxes.
- Dormancy statement: on today's boxes the env is unset → one log line
at arm, zero API calls, zero writes. With the env set but one box: the
steering hostname + any steered hosts get single-edge latency records
@@ -334,7 +340,7 @@ func desiredState(routes []registry.Route, edges []edgesFileView, appsDomain str
dated housekeeper-not-console DNS-writer amendment; Route53-only
note; the pool-rail v1 note) + this plan's rollout notes.
-- [ ] Fill rollout notes from the ledger + these knowns: arming order
+- [x] Fill rollout notes from the ledger + these knowns: arming order
per deployment = tofu apply (steering_zone_ids) → set
`CARLOS_STEERING_ZONE_ID` on the box → verify the housekeeper's
converge log → steer one guinea-pig host → dig from two vantage
@@ -344,13 +350,69 @@ func desiredState(routes []registry.Route, edges []edgesFileView, appsDomain str
commands ready-to-run); edges-file `role` over-claim note (steering
reads the ARMED-EDGE set, not roles — unaffected, but read-local
still wants the replica role first, restated).
-- [ ] Full gate; commit `docs: steering rollout notes`.
+- [x] Full gate; commit `docs: steering rollout notes`.
## Rollout notes (filled by Task 8 at execution)
-- (filled at execution)
-
-Pre-filled by Task 7 (fold into the list above at Task 8):
+This plan ships stage 4 (spec Part 4) dormant on single-box deployments —
+nothing below is armed by merging this branch. It is the checklist and
+open-questions list for the stage-5/6 rollout that follows, synthesized
+from the SDD ledger (`progress.md`) across all seven implementation
+tasks and their reviews, reconciling plan-2's (multi-region-backhaul)
+rollout notes and constraints where they name Part 4 directly.
+
+### Arming checklist (per deployment, in order)
+
+1. **Confirm plan-2's stage-5 checklist items 1–2 already hold for this
+ deployment before arming steering at all** (consumed here, not
+ repeated in full — see `2026-08-22-multi-region-backhaul`'s rollout
+ notes): every box meant to be armed has published
+ `control/edges/<box>.json` (`aws s3 ls
+ s3://<control-bucket>/control/edges/` — one object per armed box,
+ no gaps), and `control/edge-mesh-key` exists and every armed box's
+ mirror has loaded it. Steering's desired-state fold only ever sees a
+ box through its edges file (Part 4's `armedEdges`), so a box missing
+ one is invisible to the converger exactly as it is invisible to the
+ home map — it gets no latency records and answers no traffic, silently.
+2. `tofu apply` with `steering_zone_ids` (and, per the IAM note below,
+ `steering_record_names`) set for the deployment's region-host module —
+ **Paul's applies rule**: tofu apply sessions are blocked on Paul
+ running them; this plan does not run one. Have the exact command
+ ready (module path + var values) so the apply is a single approved
+ step, not a design conversation at apply time.
+3. Set `CARLOS_STEERING_ZONE_ID` on **both units on both armed boxes** —
+ the agent unit (converger duty + edge probe + wildcard parent, one
+ process) and the console unit (custom-domain CNAME guidance). See the
+ "two units" mechanism note below; a unit that misses the env stays
+ silently on old behaviour, not broken, so this step has no error
+ signal of its own — the next two steps are how you catch a miss.
+4. Verify the housekeeper's converge log on whichever box holds the
+ lease (`journalctl -u carlos -g "^steering:"` — this is the
+ canonical operator surface, not the housekeeper's own error log; see
+ the duty-status mechanism note below) — expect the "steering loop
+ armed... converges only while this box holds the housekeeper lease"
+ line, then an actual convergence pass once records exist.
+5. **Per-EIP wildcard verification BEFORE treating steering as live —
+ this is a hard gate, not a nice-to-have.** For every armed EIP:
+ `curl --resolve edge.<apps-domain>:443:<EIP> https://edge.<apps-domain>/`
+ and expect the steering-probe 200 body
+ (`carlos steering probe ok`), or confirm the wildcard line in that
+ box's journal (`edge: wildcard *.<apps-domain> obtained` on the
+ issuer, `loaded from cache` on the others). Until every EIP passes
+ this, some of the health checks Route53 is about to run WILL fail at
+ the handshake — see the wildcard-propagation mechanism note.
+6. Steer one guinea-pig host (`carlos steering -app X -host H latency`)
+ — pick something low-stakes, per the spec's flagship-AU-first
+ rationale (a mistake here "burns nobody's ticket sales").
+7. Dig from two vantage points (different geographic resolvers — e.g. a
+ local machine plus a cloud shell in another region) and confirm each
+ gets a DIFFERENT armed EIP back, consistent with its own latency to
+ each edge. A single vantage point cannot distinguish "latency routing
+ worked" from "there is only one record."
+8. Only after 1–7 hold for the first armed pair: repeat for the next
+ region, then move to the next deployment (tito UK/SE, stage 6).
+
+### Mechanism notes
- **A dead box is never retired by anything but the health check.** A box
writes `control/edges/<box>.json` and nothing ever deletes it: an
@@ -403,3 +465,221 @@ Pre-filled by Task 7 (fold into the list above at Task 8):
armed**, not just the local box's: the DNS check that stamps a custom
domain `pointing` compares the resolved address against that list, and a
steered name resolves to whichever edge the resolver is nearest.
+- **The desired set comes from BUCKET RECORDS, never the lease-holder's
+ local registry.** `provision.ListAllRecords` (Task 6's review fix,
+ `dc30c28`) walks `accounts/` + `apps/` FAIL-CLOSED: any unreadable
+ account manifest or any record `Get` failure aborts the whole read with
+ no partial result, because a short list here is indistinguishable from
+ records having been deleted and would NXDOMAIN a live host. This is why
+ a housekeeper lease migration does not flap home-boxed steered hosts —
+ every box reads the same bucket truth regardless of which one holds
+ the lease.
+- **A refused pass reports green.** `steeringDuty` returns `nil` whether
+ the tick actually converged or was refused by one of the two safety
+ guards (empty desired-set, or the shrink guard below) — deliberately,
+ so a routine 5-minute error cadence does not bury the one log line that
+ explains what happened. The housekeeper's own success/failure surface
+ is therefore NOT where steering health lives: read the journal
+ (`journalctl -u carlos -g "^steering:"`) after any arming or
+ retirement action, don't trust a clean housekeeper status alone.
+- **A GENUINE >half-record withdrawal has no escape hatch.** The
+ proportional shrink guard (Task 5, hardened in the fix rounds) refuses
+ to delete more than half the zone's managed records — or more than a
+ small floor for tiny zones — on EVERY tick, forever, once triggered,
+ with no override flag. This is deliberate (an accidental multi-box
+ retirement should never silently wipe a zone), but it means a REAL
+ multi-box retirement needs an operator action, not a wait: either
+ delete the excess records by hand in Route53, or retire boxes in
+ batches that each stay under the limit. Threading a status flag back
+ through the housekeeper duty contract to make this self-healing was
+ considered and deliberately left out (Task 6) — it would change the
+ contract for every housekeeper duty, not just this one.
+- **"Zero API calls" when dormant is honest about Route53, not about
+ every credential path.** The steering duty's own dormancy log says
+ "the duty reads nothing and calls no AWS API," which is true for
+ Route53 — `steering.NewClient` is only constructed inside the
+ zone-armed branch. It is NOT a claim that a dormant box touches
+ nothing at all: `openDeployStore` (common to every housekeeper duty,
+ not steering-specific) resolves AWS credentials through the default
+ chain, which may reach IMDS regardless of whether steering is armed.
+ Read "dormant" as "no Route53 calls, no DNS writes" — not literally
+ zero network activity.
+- **An empty accounts listing is a theoretical failure mode, not a
+ designed-for one.** If `provision.ListAllRecords` ever returned
+ success with zero accounts (every account manifest simultaneously
+ unreadable in a way that somehow does not trip the fail-closed path —
+ not reachable by any known code path today, but named here because the
+ consequence would be severe if it ever happened), every tenant's
+ steered records would look managed-but-unwanted and the ONLY thing
+ standing between that and mass deletion is the shrink guard above.
+ Worth an eye during arming (does the first real convergence log a
+ record count that matches expectations?), not a blocker.
+- **A soft-deleted account's records are a deliberate, silent drop —
+ correct, not a bug.** `knownAccounts` (`internal/provision/reconcile.go`)
+ skips any account manifest with `m.Deleted() == true`, so that
+ account's records fail `ParseRecordKey`'s known-account guard and
+ never reach `ListAllRecords`'s output. DNS follows the same retirement
+ `Reconcile` already applies elsewhere — an account a member deleted
+ should not keep steering traffic. `ListAllRecords`'s own doc comment
+ does not currently name this behaviour explicitly (Task 6 minor,
+ triaged below as a fix-wave candidate); the behaviour itself is
+ correct and needs no change.
+
+### Constraints from plan 2 (multi-region-backhaul), checked against what shipped
+
+- **Pool narrowing (backhaul plan's constraint #6): honored, and more
+ strictly than asked.** The constraint required that a steered
+ non-shared-pool route only get latency records for the armed edges
+ that actually serve its pool. Task 3's v1 pool rail goes further: the
+ console door and CLI REFUSE `steering:"latency"` outright on any route
+ whose `Pool != ""` (409), so no pool-scoped route ever reaches the
+ desired-state fold in the first place; the fold's own defensive
+ exclusion (logged) is the belt-and-suspenders backstop for a route
+ that reaches the bucket by some other path. Per-pool record scoping —
+ the constraint's original, narrower ask — is recorded as future work
+ (spec "Anticipated, not built" §3).
+- **Edges-file `role` over-claim (backhaul plan's item 9): resolved by
+ construction, not by a role change.** The concern was that Part 4
+ might read the `role: "home"` field a static-route adopter over-claims
+ and make a wrong read-local decision. Confirmed by code
+ (`internal/steering/steering.go`'s `edgesFileView`): the steering
+ converger's edges-file reader carries only `V`/`Box`/`EIP`/`Region` —
+ `Hosts`/`role` is deliberately absent, with a doc comment stating why
+ (steering is any-edge-any-app; the fold never needs to know which
+ hosts a box homes). Steering is therefore unaffected today. The
+ underlying want — a `"replica"` role before the read-local
+ anticipation can trust this map — is restated in the spec (Part 3
+ amendment) as still open for whoever builds that feature, not solved
+ here.
+- **T7 egress ruling (backhaul plan's item 5): stays open, no issue
+ number yet.** Under-metered, abuse-surface-shaped egress for
+ backhauled bytes — billed on the sending box's AWS account, never
+ attributed in CARLOS usage — needs Paul's ruling on the containment
+ approach (a per-home-box in-flight semaphore is the option on the
+ table) before it gets a numbered issue, per the backhaul plan's own
+ note. That plan flagged this as needing resolution BEFORE steering
+ sends real, sustained cross-region traffic — which stage 5 of this
+ plan is about to start doing. **Recommendation: raise this with Paul
+ before or alongside the stage-5 arming decision, not after** — the
+ window the backhaul plan called out ("today it is dormant/low-volume
+ by construction... which is the window in which to decide, not
+ after") ends the moment stage 5 begins.
+
+### Deferred-minor triage (every task's ledger entry, included or excluded with a reason)
+
+**Task 1** (stage-5 gates):
+- seed degrade-path untested (no-prior/corrupt → nil) — RECORDED. Has a
+ behavioural precedent (the snapshot loop's analogous case) to pin
+ against later; no known defect.
+- backhaul throttle key-isolation unpinned package-wide — FIX-WAVE
+ CANDIDATE. The suggested pin (stub `logThrottled` + erroring
+ `RoundTripper`) is cheap and non-flaky; a future constant-key refactor
+ could silently collapse per-box throttle buckets with no test to catch
+ it.
+- edgesFileView needs `region` when Task 4 consumes it — DONE. Task 4
+ added it (`Region` field, `edgesFileView`); closed, no action.
+- spec Part 3 prose lacks region — DONE, this task (spec amendment
+ above, Part 3's Home-resolution section).
+
+**Task 3** (console door + CLI):
+- pool 409 on an addrs-only patch over a hand-written already-steered
+ +pooled bucket object — RECORDED. Unreachable via any supported
+ writer; a wording note only if a hand-edited bucket object ever
+ produces it in practice.
+- steering-verb refusal branch untested (`os.Exit` path) — FIX-WAVE
+ CANDIDATE. Same-shaped precedent already exists elsewhere in the CLI
+ test suite to copy from; cheap to add.
+- `provision.go:113`'s "Task 3" prose reads as forward-looking —
+ RECORDED, not fixed here. Verified: it refers to a DIFFERENT plan's
+ Task 3 (`2026-08-04-instance-provisioning-design`), not this plan's —
+ stale-sounding but not factually wrong, and outside this task's
+ declared file scope (spec + this plan only). One-line doc-comment
+ polish for a future docs pass.
+
+**Task 4** (desired state):
+- 3-way collision subtest wanted — FIX-WAVE CANDIDATE. Correct by trace
+ through `dedupeCaseCollisions`/`dedupeRecords`, but unpinned by a test
+ that actually exercises three colliding rows at once.
+- a member steering a route literally named `edge.<apps-domain>` —
+ RECORDED, verified this session. `dedupeRecords`'s (Name, Box) key
+ collision catches it (first kept, collapse logged); confirmed by
+ reading `internal/steering/steering.go`'s `desiredState`/`dedupeRecords`
+ directly. No code change needed.
+
+**Task 5** (Route53 converger) — the largest minors list, and the one
+most already self-documented as deliberate scope notes in the code
+itself (`internal/steering/route53.go`), verified by direct reading this
+session rather than assumed from the ledger:
+- `steeringHostname` matches any `edge.*` prefix (order-dependence on
+ the synthetic record being first) — RECORDED. Documented in code;
+ `desiredState` always emits the synthetic record first, deterministically.
+- malformed record wedges-vs-skip-loudly — RECORDED. Matches
+ `ListAllRecords`'s documented skip-and-log policy; consistent with
+ every other defensive layer in this package, not a gap.
+- duplicate-check self-heal takes two passes (comment reads optimistic) —
+ RECORDED. Self-heals correctly; a comment-accuracy nit, not a defect.
+- GC's foreign-surviving-reference gate untested — FIX-WAVE CANDIDATE.
+ Safety-relevant code path (`gcHealthChecks`'s "still referenced, leave
+ it" branch) with no direct test.
+- 32KB batch limit unmentioned — RECORDED. `changeBatchLimit` is already
+ conservative relative to Route53's real cap; a documentation nicety.
+- wanted-key self-defense in the delete loop (relies on an upstream
+ guarantee) — RECORDED, see the `wantKey`/`recordKey` item next; same
+ underlying invariant.
+- `wantKey` builds `rec.Name` unlowercased while `recordKey` explicitly
+ lowercases — the ledger flagged this for "final review" to triage.
+ Verified this session: `recordKey`'s own comment already states the
+ invariant honestly (`desiredRecord.Name` is lowercase by contract, via
+ `steering.go`'s `fqdn`) — the risk is a silent miss ONLY if some future
+ caller ever handed `wantKey` an unfolded name, which nothing in this
+ package does today. Triage: RECORDED as an accepted, documented risk
+ (matches this file's own "structural backstop" pattern elsewhere);
+ FIX-WAVE CANDIDATE for defense-in-depth (a one-line
+ `strings.ToLower(rec.Name)` in `wantKey` would remove the invariant
+ dependency entirely) — not urgent, since the fold that feeds it cannot
+ currently produce a counterexample.
+
+**Task 6** (housekeeper duty):
+- "zero API calls" dormancy overclaim — ADDRESSED, this task (mechanism
+ note above corrects the wording: no Route53 calls, not literally zero
+ network activity).
+- edges files have no retirement path — ALREADY CARRIED (Task 7's
+ pre-filled dead-box mechanism note above; Task 6 independently flagged
+ the same fact).
+- keyed parse-log variant when the `[][]byte` pin lifts (~6 lines) —
+ RECORDED. A forward-looking hook for a refactor that has not happened;
+ no current need.
+- duty-status surface + >half-withdrawal no-escape — ADDRESSED, this
+ task (spec's Failure-modes bullet + the two mechanism notes above).
+- soft-deleted account doc naming on `ListAllRecords` — FIX-WAVE
+ CANDIDATE. Cheap, correctness-adjacent doc-comment addition (behaviour
+ itself is verified correct, see the constraint section above).
+- `ListAllRecords` wants its own package-level table test — FIX-WAVE
+ CANDIDATE. New exported surface with only indirect test coverage today
+ (via the steering loop's tests).
+- successful-but-empty accounts listing (theoretical) — ADDRESSED, this
+ task (mechanism note above).
+- stale no-registry-reads test comment — FIX-WAVE CANDIDATE. Trivial
+ wording fix in a test file, no functional impact.
+
+**Task 7** (IAM + guidance): no standalone minors — every fix-round item
+(the autocert reasoning correction, the trailing-dot normalization, the
+IAM name bound, the two-units correction, the disclosure sentence) was
+folded into either the code or the pre-filled mechanism notes above by
+the task's own two fix rounds. Nothing left to triage.
+
+### For Paul
+
+- **The tofu applies** (arming checklist step 2): `steering_zone_ids` +
+ `steering_record_names` for each deployment's region-host module —
+ commands ready, not run by any agent session per the applies rule.
+- **The env sets** (arming checklist step 3): `CARLOS_STEERING_ZONE_ID`
+ on both units on both boxes, per deployment, alongside the apply.
+- **The T7 egress ruling** (cross-ref above): a decision on the
+ containment approach (or an explicit "ship steering first, decide
+ later") is needed before or alongside the stage-5 arming decision —
+ raised here rather than left buried in plan 2's own notes, since this
+ is the plan whose stage-5 work makes the ruling time-sensitive.
+- **PR routing**: this branch merges to `main` directly — no stack, no
+ base branch other than `main` (see the PR body, drafted in the task
+ report).
diff --git a/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md b/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md
index 19e15f475..e7ceec3e8 100644
--- a/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md
+++ b/docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md
@@ -240,6 +240,33 @@ one-label wildcard rule (`internal/edge/edge.go`) itself: an exact
match wins, and on a miss the first label's leading dot is swapped for
`*` before trying again.
+**Amendment (2026-08-22, T8, on Task 1's stage-5 gate):** the file
+gained an additive `region` field (commit `07a41eb`) — the AWS region
+(e.g. `eu-west-1`) IMDS reports at loop arm
+(`/latest/meta-data/placement/region`), overridden by
+`CARLOS_AWS_REGION` for testing/non-EC2. Deliberately NOT `CARLOS_REGION`
+(the CARLOS placement code, e.g. `ie`) — an earlier plan sketch
+conflated the two, caught on Task 1's own review before it shipped.
+Empty on a non-EC2 box, which correctly leaves that edge unarmed for
+steering (Part 4's latency records route by AWS region); Task 4's fold
+additionally validates the AWS-region pattern
+(`^[a-z]{2}(-[a-z]+)+-\d$`) before treating a region as present, never
+just non-empty.
+
+**Part 4's steering converger deliberately does NOT read `Hosts`/`role`
+from this file** — its own `edgesFileView`
+(`internal/steering/steering.go`, a separate declaration from this
+section's reader) carries only box/EIP/region, because steering is
+any-edge-any-app and never needs to know which hosts a box homes. The
+`role: "home"` over-claim on adopted static/everywhere-class hosts
+(every box that adopts a static route publishes it as `"home"`,
+harmless today because static routes never backhaul — recorded in the
+`2026-08-22-multi-region-backhaul` plan's rollout notes) is therefore
+unaffected by and irrelevant to Part 4. It stays live for the
+read-local anticipation below (§"Anticipated, not built"), which WILL
+need a `"replica"` role added before it can trust this map — restated
+here as the still-open pointer, not solved by this plan.
+
**The hop.** A remote edge dials `home-EIP:443` with ordinary SNI — the
home edge presents the same bucket-distributed cert. Two headers make it
a trusted hop: an HMAC over (host, timestamp) keyed by
@@ -264,10 +291,34 @@ singleflight, #199), and a backhaul hop is just another first client.
**Steering opt-in.** New additive record field `steering` (string;
`"latency"` is the only v1 value; absent = today's single-region DNS).
-Member-settable via console and CLI like any record field;
-fingerprint-covered; rides the #137 record shape. Serving capability
-exists on every edge regardless — steering only controls whether DNS
-sends anyone there.
+Member-settable via console and CLI like any record field; rides the
+#137 record shape. Serving capability exists on every edge regardless —
+steering only controls whether DNS sends anyone there.
+
+**Amendment (2026-08-22, T8, correcting "fingerprint-covered" above, on
+Task 2's review ruling):** `SnapshotFingerprint` rides the full
+registry column set and DOES cover `steering`, like every other column.
+The edge-relevant `Fingerprint` set does NOT — it is a hand-curated
+reload-relevance list, and steering has no edge consumer to reload for
+(the converger is a HOST duty reading bucket records directly, below,
+never something an edge watches for a config change).
+`shapeMismatch` does not compare it either, for the same reason
+Channel/Environment are excluded: it is a record-owned column, and
+comparing it would let a route carrying a stale registry-cached value
+refuse adoption forever — the exact case adoption exists to fix. Both
+exclusions are deliberate (`internal/provision/reconcile.go`,
+`internal/registry/registry.go`), recorded there as such, not a gap.
+
+**Amendment (2026-08-22, T8, v1 pool rail, from the plan-2 ledger):**
+the console door and CLI REFUSE `steering:"latency"` on a route whose
+`Pool != ""` (409, naming the constraint) — a pool-scoped route's
+latency records would point at edges that cannot structurally serve it,
+since only the boxes configured for a pool ever serve it. The
+desired-state fold (Part 4) also excludes any pool-carrying steered
+route defensively, with a loud log, in case one ever reaches the bucket
+by a path other than the door. Per-pool record scoping — steering a
+pool-scoped route to only the edges that serve its pool — is a recorded
+future (§"Anticipated, not built"), not v1.
Consequences stated honestly:
- an opted-in addr route is probed by N edges — N× probe cadence on the
@@ -279,27 +330,109 @@ Consequences stated honestly:
## Part 4 — steering mechanics, failure, rollout, testing
-**DNS.** Route53 latency records (both production zones are already
-Route53). A steered apps-domain hostname gets one latency record per
-armed edge, each bound to a Route53 health check on that edge's EIP
-(HTTPS + SNI), TTL 60. Console-driven through the existing zone
-machinery — never tofu-per-app. Custom domains: CNAME to a steering
-hostname (shape: `edge.<apps-domain>`, itself latency-routed) gets full
-steering; apexes cannot CNAME, so apex guidance remains
-A-records-to-all-EIPs — round-robin without latency bias, documented as
-such in attach guidance. Edge death: health checks withdraw its records
-in ~90s globally — steered routes thereby get most of rung-2 warm
-standby for free.
+**Implementation status (2026-08-22): implemented, dormant.** The
+per-route `steering` field and its 4-place registry column
+(`76c4482`..`15377fb`); the console door + CLI setter with the pool
+rail (`02222c5`, `35ce275`); the desired-state fold — steered hosts ×
+armed edges, case-fold dedupe, the synthetic `edge.<apps-domain>`
+steering hostname (`8623687`..`ac2e728`); the Route53 converger — the
+managed-set boundary, latency records, health checks, the shrink guard,
+plain/CNAME/policy conflict handling (`7ca22b1`..`39b36ec`); the
+housekeeper duty wiring, reading bucket records rather than the local
+registry (`08e134d`, `dc30c28`); and the IAM surface, custom-domain CNAME
+guidance, and the steering health-probe's cert coverage plus 200 handler
+(`e83dc6f`..`460ac9b`) are all merged. Every box today has
+`CARLOS_STEERING_ZONE_ID` unset, so the duty logs once at arm and calls
+no AWS API — this is the stage-4 code drop; stage 5 (arming a real
+deployment) has not run anywhere, see the plan's rollout notes for the
+sequence.
+
+**DNS.** Route53 latency records — **Route53-only in v1**: no DNSimple
+path for steering exists or is planned near-term; both production zones
+are already Route53, so this is a stated scope boundary, not a gap. A
+steered apps-domain hostname gets one latency A record per armed edge
+(TTL 60), `SetIdentifier` = the box label, `Region` = the edge's AWS
+region (the `region` edges-file field, Part 3's amendment above), bound
+to a Route53 health check on that edge's EIP.
+
+**Amendment (2026-08-22, T8, dated correction — the plan's Global
+Constraints flagged this before Task 6 shipped, and this spec's original
+wording ["Console-driven through the existing zone machinery"] is
+corrected here rather than left standing):** the console SETS the
+`steering` field (member-settable, per Part 3's amendment above) — it
+never touches Route53. The elected HOUSEKEEPER converges the zone, on
+its own
+5-minute-plus-jitter loop, and reads the desired set from **bucket
+records** (`provision.ListAllRecords`), never the lease-holder's local
+registry: routes are not uniformly provisioned (an exec/unit/socket
+route is homed to exactly one box and is therefore absent from every
+other box's registry), so a registry-sourced desired set would flap a
+home-boxed steered host to NXDOMAIN on every housekeeper lease
+migration — a real defect Task 6's review caught and fixed (`dc30c28`),
+not a hypothetical. "Never tofu-per-app" stands unchanged: tofu only
+ever provisions the zone-scoped IAM grant (Part 4's IAM below), never a
+per-route record.
+
+**The health check**, corrected from an earlier sketch that only said
+"HTTPS + SNI": `FullyQualifiedDomainName` and the TLS SNI are the
+synthetic steering hostname `edge.<apps-domain>` — never a member's own
+hostname — port 443, `GET /`. That hostname is one label SHALLOWER than
+the per-account wildcard parents an edge otherwise holds, so answering
+it needed its own mechanism, both halves verified in code rather than
+assumed: with steering armed, `*.<apps-domain>` itself joins the elected
+issuer's wildcard parent set (DNS-01 is the only path that can work
+here — the sole client that ever sends this SNI is the health checker,
+so a per-host order triggered lazily by the handshake would be
+cancelled with it, and a per-host exception would breach the
+exact-registry `Allowed` rule from PR #180 in two places); and every
+edge answers a dedicated 200 for `GET /` on that host, placed ahead of
+all other routing, because the hostname is not a registry route and
+would otherwise 404 — or, on a `-fallback` edge, 302, which Route53
+scores as healthy either way, a false-healthy check that never
+withdraws anything and explains nothing in the journal. Disclosed cost,
+the same property every per-account wildcard parent has had since it
+existed: a stranger's unknown one-label host under the apps domain now
+completes the TLS handshake before reaching the ordinary 404/302, where
+before arming it failed at the handshake. Non-issuer edges cannot answer
+the check until the wildcard reaches them — the cert mirror's 30s poll
+is the normal path, an hourly stand-down pass is the fallback if that
+wake is ever missed — so confirm per-EIP before treating steering as
+live; see the plan's rollout notes for the exact sequence.
+
+Custom domains: CNAME to the steering hostname (`edge.<apps-domain>`,
+itself latency-routed) gets full steering; apexes cannot CNAME, so apex
+guidance remains A-records-to-all-EIPs — round-robin without latency
+bias, documented as such in attach guidance. Edge death: health checks
+withdraw its records in ~90s globally — steered routes thereby get most
+of rung-2 warm standby for free, **provided an operator has also deleted
+the dead box's `control/edges/<box>.json`**: nothing else ever retires
+that file, so a terminated box's EIP is contributed to every fold
+forever and the health check is the only withdrawal (of the answer, not
+the record) — see the plan's rollout notes.
**Failure modes.**
- Edge dies: steered → fails away via health checks; unsteered routes
- homed there are down until rung-1 recovery (unchanged today).
+ homed there are down until rung-1 recovery (unchanged today). Full
+ retirement is a two-step operator action (stop the box, then delete
+ its edges file) — see the DNS paragraph above.
- Issuer dies: Part 2 — costs nothing for ~30 days.
- Control-bucket region outage: global control freeze, edges serve
last-converged state. Accepted.
- Stale home map during a move: one 503 + Retry-After, healed in
seconds; the hop guard makes loops structurally impossible.
- Mesh-key rotation: dual-accept window.
+- **A genuine withdrawal of more than half the managed records** (a real
+ multi-box retirement) is refused by the converger's shrink guard on
+ EVERY tick, forever, with no override flag — by design, the same
+ refusal that protects against a short/truncated read. Recovery is an
+ operator deleting the records by hand, or withdrawing boxes in batches
+ under the limit. The housekeeper duty reports success (nil) on a
+ refused pass regardless — both safety refusals log loudly and return
+ nil, deliberately, so a five-minute error cadence does not bury the
+ one line that says what happened — so the operator surface for
+ steering health is the JOURNAL
+ (`journalctl -u carlos -g "^steering:"`), never the housekeeper's own
+ error log.
**Rollout.** Stages 1–4 land dormant on single-box deployments
(one-candidate elections; per-box keys with one box; no steered routes):
@@ -308,10 +441,16 @@ standby for free.
2. Issuer election + cert distribution + challenge-anywhere.
3. Backhaul: edges files, mesh key, hop serving + guard.
4. Steering: record field, console/CLI setter, Route53 latency records +
- health checks (tofu for the health-check scaffolding, console for
- per-host records).
+ health checks — tofu provisions the IAM scaffolding only; the elected
+ housekeeper converges per-host records, never the console (corrected
+ 2026-08-22, T8; see the DNS paragraph's dated amendment above).
+ **Implemented, dormant** (this Part's implementation-status block
+ above).
5. Arm flagship AU alone; steer one static site and one exec app;
- verify from real vantage points; then UK/US.
+ verify from real vantage points; then UK/US. **Not started — this is
+ the next operator action**, detailed in the plan's rollout notes
+ (tofu apply → env on both units → per-EIP wildcard verification →
+ guinea-pig host → vantage-point dig).
6. Tito UK/SE: new region hosts + `carlos-tito-{uk,se}` data buckets in
teamtito/carlos-infrastructure; second deployment of a proven
pattern. (This is where the original UK/SE member request lands —
@@ -326,6 +465,18 @@ with `curl --resolve` and geo vantage checks; the Route53 record shape
gets a tofu-plan golden. Every stage keeps the full `go build/vet/test`
gate green with stages dormant.
+**Amendment (2026-08-22, T8):** the steering sentence above describes
+the STAGE-5 ROLLOUT verification, not automated test coverage that
+exists today — stated plainly so Part 4's claims match what actually
+shipped. The automated coverage is Task 5's fake-Route53-API suite
+(`internal/steering/route53_test.go`) and Task 7's steering-probe suite
+(`internal/edge/steeringprobe_test.go`); there is no tofu-plan golden —
+the region-host module has no test harness at all (stated at Task 7;
+`tofu validate`/`tofu fmt` are the only automated tofu checks) — and the
+`curl --resolve`/geo-vantage checks are manual, operator-run steps, not
+CI. See the plan's rollout notes for the exact arming and verification
+sequence.
+
## Anticipated, not built
Committed shapes so neither feature requires unwinding this spec:
@@ -348,6 +499,13 @@ Committed shapes so neither feature requires unwinding this spec:
then-cutover — the opposite of fleet-placement's D6
state-does-not-follow ruling, deliberately), and region-aware unit
economics.
+3. **Per-pool steering** (added 2026-08-22, T8): v1 refuses
+ `steering:"latency"` outright on any route whose `Pool != ""` (Part 3's
+ pool-rail amendment) rather than scoping its latency records to the
+ armed edges that actually serve that pool. Scoping the desired-state
+ fold by pool membership is the natural extension — the fold already
+ carries `Pool` on every candidate route — deferred because no
+ pool-scoped deployment steers yet, not because it is hard.
## Non-goals