carlos / platform Public

plan: multi-region plan 3 of 3 — steering (spec Part 4)

Eight tasks, dormant until CARLOS_STEERING_ZONE_ID arms: the two
stage-5 gates from plan 2's final review land first (seeded edges
gate + region field + stuck-state visibility); then the steering
field (additive, 4-place, pool-railed at the door), the pure
desired-state fold (steered hosts x armed edges + the always-on
edge.<apps-domain> custom-domain target), a Route53 converger bounded
to its managed record set with per-EIP health checks, the housekeeper
duty (mintMeshKeyLoop's shape), IAM surface, and rollout notes. Spec
amendment recorded: the DNS writer is the housekeeper, not the console
(writer-map consistency; "never tofu-per-app" unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 93d2f166fda5e3728c647e961947fcc7213df1e8 parent 920d599
1 file changed, +336 −0
  • docs/superpowers/plans/2026-08-23-multi-region-steering.md +336 −0
diff --git a/docs/superpowers/plans/2026-08-23-multi-region-steering.md b/docs/superpowers/plans/2026-08-23-multi-region-steering.md
new file mode 100644
index 000000000..2bb55264a
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-23-multi-region-steering.md
@@ -0,0 +1,336 @@
+# Multi-region ingress, plan 3 of 3: steering (spec Part 4)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use
+> superpowers:subagent-driven-development (recommended) or
+> superpowers:executing-plans to implement this plan task-by-task. Steps
+> use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship spec Part 4 — the per-route `steering` opt-in, the Route53
+latency-record + health-check converger, and the custom-domain steering
+hostname — dormant until a deployment arms `CARLOS_STEERING_ZONE_ID`.
+
+**Architecture:** `steering` is an additive record/registry field
+(`"latency"` or absent), member-settable through the console door and
+CLI, refused on non-shared-pool routes (v1 guardrail from the plan-2
+ledger). A housekeeper duty (the one election) converges Route53: one
+latency A record per steered host per armed edge (armed = present in
+`control/edges/` with an EIP; SetIdentifier = box label; Region = the
+edge's AWS region, an additive edges-file field), each bound to a
+per-EIP HTTPS health check; withdrawal and health-check GC are part of
+the same convergence. `edge.<apps-domain>` is the always-steered CNAME
+target for custom domains. Task 1 first lands the two
+MUST-BEFORE-STAGE-5 gates from plan 2's final review.
+
+**Tech Stack:** Go; aws-sdk-go-v2 route53 service package (new direct
+dep beside the existing SDK family); the edge-side `route53dns.go`
+provider shows the house auth/client idioms; `release.Store` for state
+reads. No DNSimple path for steering (Route53-only; both production
+zones are Route53 — dated spec note in Task 8).
+
+## Global Constraints
+
+- Spec: `docs/superpowers/specs/2026-08-20-multi-region-ingress-design.md`
+ Part 4 (as amended through main 920d599). Steering field values:
+ exactly `""`(absent) and `"latency"` — a string, never a bool.
+- **Writer-map rules**: nothing writes `control/serial` from a box; the
+ DNS converger is a HOUSEKEEPER duty (Task 8 amends the spec's
+ "console-driven" phrasing, dated — the console SETS the field; the
+ elected housekeeper CONVERGES DNS; "never tofu-per-app" unchanged).
+- **Registry column rule**: adding `steering` touches FOUR places
+ (ALTER list, routeCols, scanRoute, Upsert) — currently 35 columns,
+ becomes 36; `TestUpsertRoundTripsAllColumns` catches a miscount.
+ `Fingerprint` and `SnapshotFingerprint` gain it automatically via
+ routeCols — pin with a test, don't assume.
+- **Wire-shape changes are additive**: `steering` and the edges-file
+ `region` field are omitempty; records written before this plan parse
+ unchanged (round-trip test on a pre-plan fixture).
+- **Pool guardrail (v1, from plan-2 ledger)**: the door and CLI REFUSE
+ `steering:"latency"` on a route whose Pool != "" — a steered
+ non-shared-pool route would get latency records pointing at edges
+ that structurally cannot serve it. Refusal text names the constraint;
+ the per-pool-record-scoping alternative is a recorded future, not v1.
+- **Dormancy**: with `CARLOS_STEERING_ZONE_ID` unset the converger duty
+ returns immediately (log once at arm, then silent); setting the field
+ on a route changes NOTHING until a deployment arms the zone. Every
+ task states its inertness.
+- Gate before every commit: `go build ./... && go vet ./... &&
+ go test ./... -count=1`, `GOOS=darwin go build ./...`, `gofmt -l`
+ clean on touched files, `-race` on packages with concurrency tests,
+ env `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`
+ (if /tmp fills: `go clean -cache`).
+- Production channel names (`internal/release/laddercheck_test.go`)
+ untouched. carlosTestEnv + scratch cmd.Dir for CLI-shelling tests.
+
+## File Structure
+
+- `internal/host/edgesfile.go` — Task 1 (seed `last` from bucket; fix
+ the :124 heal-cycle comment; additive `region` field)
+- `internal/edge/homemap.go`, `internal/edge/edge.go` — Task 1
+ (stuck-state visibility)
+- `internal/provision/provision.go`, `internal/registry/registry.go`,
+ `internal/provision/reconcile.go` — Task 2 (the field, 4-place)
+- `internal/console/apiinstances.go`, CLI `instances_cli.go`/`cli.go` —
+ Task 3 (door + CLI + pool rail)
+- `internal/steering/steering.go` + `route53.go` + tests — Tasks 4-6
+ (desired-state builder, converger, fake-API tests)
+- `internal/host/agent.go`, `internal/host/housekeeper.go` — Task 4
+ (the duty loop, mintMeshKeyLoop's shape)
+- `infra/modules/region-host/` — Task 7 (IAM)
+- Spec + this plan — Task 8
+
+---
+
+### Task 1: The stage-5 gates (plan-2 final-review debts)
+
+**Files:**
+- Modify: `internal/host/edgesfile.go` (loop at ~:122-153; the :124
+ comment; the pass's schema struct)
+- Modify: `internal/host/edgesfile_test.go`
+- Modify: `internal/edge/homemap.go` (`staleHopRefusal` ~:545-565)
+- Modify: `internal/edge/edge.go` (the backhaul 502 ErrorHandler ~:1962)
+
+**Interfaces:**
+- Produces: the edges-file schema gains `"region":"<aws-region>"`
+ (omitempty; value = `CARLOS_REGION` env, empty when unset) — Task 4's
+ converger requires it for latency records. `edgesFileLoop` seeds its
+ change-gate from the bucket at arm.
+
+- [ ] **Step 1: failing tests** — (a) seed: pre-populate the fake store
+ with a prior edges file, run one pass with identical registry state,
+ assert ZERO writes (today it always writes on first tick); a pass with
+ a transient blank EIP right after arm keeps the bucket's EIP (the
+ restart-blip case from the final review). (b) region: the raw wire
+ body carries `"region":"eu-west-1"` when the env is set, omits it
+ when unset (extend the existing raw-body pin). (c) visibility:
+ `staleHopRefusal` logs first-and-every-tenth per (box,host) like the
+ file's partial-poll pattern; the backhaul ErrorHandler goes through
+ `logThrottled` keyed `"backhaul-error "+box`.
+- [ ] **Step 2: verify failures. Step 3: implement.** Seeding: at loop
+ arm, Get `edgesFileKey(box)`; if present, unmarshal, recover the EIP
+ for the hold AND recompute the change-gate body (marshal the fetched
+ file with Generation cleared) into `last`. Fix the :124 comment (the
+ heal cycle is this loop + the 30s mirror — the same correction the
+ docs wave made in homemap.go). Region: read `CARLOS_REGION` once at
+ arm (the domains loop's read-once precedent), thread into the pass.
+- [ ] **Step 4:** `go test ./internal/host/ ./internal/edge/ -count=1`
+ + `-race`. **Step 5: Commit**
+ `host+edge: stage-5 gates — seeded edges gate, region field, stuck-state visibility`.
+
+---
+
+### Task 2: The steering field, storage to converge
+
+**Files:**
+- Modify: `internal/provision/provision.go` (Record struct + validation)
+- Modify: `internal/registry/registry.go` (4-place column add +
+ `SetSteering(host, steering string)` narrow UPDATE)
+- Modify: `internal/provision/reconcile.go` (`routeFor`,
+ `shapeMismatch`, `convergeOwned` carry it)
+- Test: each package's existing suites + a pre-plan-fixture round-trip
+
+**Interfaces:**
+- Produces: `Record.Steering string json:"steering,omitempty"`;
+ `ValidSteering(s string) bool` (`""` or `"latency"`);
+ `Route.Steering` registry column (36th); `SetSteering`. Task 3's door
+ and Task 4's desired-state builder consume all three.
+
+- [ ] **Step 1: failing tests** — validation table (`""` ok, `"latency"`
+ ok, `"Latency"`/`"geo"`/garbage refused); registry round-trip via the
+ existing `TestUpsertRoundTripsAllColumns` extension; `Fingerprint`
+ AND `SnapshotFingerprint` change when steering changes (pin, don't
+ assume routeCols coverage); a record JSON written WITHOUT the field
+ (pre-plan fixture, verbatim bytes from a current production-shaped
+ record) parses and round-trips with `Steering == ""`.
+- [ ] **Step 2-4: red, implement, green** (4-place rule; shapeMismatch
+ gains the Steering case so a record edit converges the row;
+ convergeOwned updates it via SetSteering). **Step 5: Commit**
+ `provision+registry: the steering field — additive, 4-place, converge-owned`.
+
+---
+
+### Task 3: Setting it — console door + CLI, with the pool rail
+
+**Files:**
+- Modify: `internal/console/apiinstances.go` (PATCH handler — the
+ existing CAS/`DerivesPaths`-rail shape at ~:649-768 is the idiom;
+ steering joins the PATCHable set with its own rails)
+- Modify: CLI (`instances_cli.go` verbs; follow the set-upstreams verb's
+ shape for a `steering` verb: `carlos steering -app X -host H latency`
+ and `... off`)
+- Test: console API tests + CLI tests (carlosTestEnv rule)
+
+**Interfaces:**
+- Consumes: `ValidSteering`, `provision.UpdateRecord` CAS,
+ `s.bumpSerial(ctx)` on success (console-mediated write — serial is
+ CORRECT here; it is the box-side that never bumps).
+- Produces: PATCH `{"steering":"latency"}` / `{"steering":""}`;
+ refusals: invalid value 400; **Pool != "" 409** with body naming the
+ v1 constraint ("steering requires the shared pool — pool-scoped
+ steering is a recorded future"); route-kind refusals NONE (any served
+ kind may steer — the spec's any-edge-any-app; home-boxed kinds steer
+ via backhaul).
+
+- [ ] **Step 1: failing tests** — door: set/clear round-trip + serial
+ bump; invalid 400; pool 409 (fixture route with Pool set); CAS 409 on
+ stale etag (existing idiom). CLI: verb round-trip against a fake
+ console; refusal text surfaced.
+- [ ] **Step 2-4: red, implement, green.** **Step 5: Commit**
+ `console+cli: steering opt-in — member-settable, pool-railed`.
+
+---
+
+### Task 4: The desired state — steered hosts × armed edges
+
+**Files:**
+- Create: `internal/steering/steering.go` + `steering_test.go`
+
+**Interfaces:**
+- Consumes: records (steered routes) via `release.Store` listing the
+ records prefix (the audit doc's enumeration recipe); edges files
+ (armed set: box, EIP non-empty, region non-empty — an edge without a
+ region CANNOT receive latency records, log-once and skip); the
+ deployment's apps domain (`PLATFORM_APPS_DOMAIN` — already box env).
+- Produces (pure functions, no AWS here):
+
+```go
+// A desiredRecord is one latency A record: name -> eip, keyed by the
+// edge that serves it. SetIdentifier is the box label; Region is the
+// edge's AWS region; HealthCheckRef ties it to the edge's check.
+type desiredRecord struct {
+ Name string // fqdn, trailing-dot normalized
+ EIP string
+ Box string // SetIdentifier
+ Region string // AWS region for latency routing
+}
+
+// desiredState folds steered routes × armed edges. The steering
+// hostname edge.<apps-domain> is ALWAYS present when any edge is
+// armed (custom-domain CNAME target). Non-shared-pool routes are
+// excluded defensively (the door refuses them; the bucket may not
+// have) with a loud log.
+func desiredState(routes []registry.Route, edges []edgesFileView, appsDomain string) []desiredRecord
+```
+
+- [ ] **Step 1: failing tests** — table-driven: one steered host × two
+ armed edges = two records with distinct SetIdentifiers/regions;
+ unsteered host absent; edge with empty EIP or region skipped (logged);
+ `edge.<apps-domain>` present whenever ≥1 edge armed, absent at zero;
+ pool-carrying steered route excluded + logged; wildcard registry rows
+ NEVER produce records (steering is per-concrete-host — a `*.parent`
+ row with steering set is skipped + logged, spec's per-host DNS rule).
+- [ ] **Step 2-4: red, implement, green (pure, no mocks needed).**
+ **Step 5: Commit** `steering: desired state — steered hosts × armed edges`.
+
+---
+
+### Task 5: The Route53 converger
+
+**Files:**
+- Create: `internal/steering/route53.go` + `route53_test.go`
+- Modify: `go.mod` (aws-sdk-go-v2/service/route53)
+
+**Interfaces:**
+- Consumes: `desiredState`; env `CARLOS_STEERING_ZONE_ID` (bare Z-id,
+ validated like the region-host module's pattern; unset = dormant);
+ AWS creds via the default chain (instance profile — same as the
+ edge's route53dns.go, read its client construction).
+- Produces: `Converge(ctx, client, zoneID string, want []desiredRecord) error`:
+ - Lists current carlos-managed records in the zone (managed = name
+ matches a steered set member OR carries the SetIdentifier scheme;
+ NEVER touches records without a SetIdentifier — the zone's static
+ records are out of bounds, stated in code).
+ - Health checks: one per distinct EIP, HTTPS:443,
+ FullyQualifiedDomainName = the steering hostname (SNI a cert every
+ edge holds), IPAddress = the EIP, tagged
+ `carlos-steering=<deployment-zone-id>`; GC checks no desired record
+ references.
+ - Upserts/deletes via ChangeResourceRecordSets batches (latency A
+ records, TTL 60, HealthCheckId bound); deletes only records whose
+ (name, SetIdentifier) is managed-but-unwanted.
+ - Idempotent: a second Converge with identical want = zero API writes
+ (the test asserts call counts on the fake).
+- [ ] **Step 1: failing tests against a fake Route53 API** (an
+ httptest-backed or interface-faked client — the SDK client is an
+ interface-shaped dependency; define `route53API` with the four calls
+ used and fake it; NO network): create-from-empty; add-an-edge;
+ remove-an-edge (record + orphaned check GC'd); remove-steering
+ (records gone, shared check retained while another host uses it);
+ idempotency zero-writes; static-record in zone untouched.
+- [ ] **Step 2-4: red, implement, green.** **Step 5: Commit**
+ `steering: route53 converger — latency records + health checks, bounded to managed set`.
+
+---
+
+### Task 6: The duty — wiring the converger into the housekeeper
+
+**Files:**
+- Modify: `internal/host/agent.go` (a `steeringLoop` beside
+ `mintMeshKeyLoop` — read that loop first; IDENTICAL shape: fleet-skip,
+ openDeployStore, 5min+jitter ticks of
+ `runHousekeeperDuties(housekeeperGate, convergeSteering)`)
+- Test: `internal/host/` loop-arming tests (mirror the mesh-key loop's)
+
+**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.
+- 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
+ (harmless — same answers as today's static DNS, and the health check
+ adds rung-2 withdrawal for steered hosts even single-box: state this
+ in the report as the first REAL behavior).
+- [ ] **Step 1: failing tests** — arming independence (no pubkey needed,
+ fleet-skip, dormant-when-unset logged once); duty invokes Converge
+ only when held (the housekeeper contract tests' shape).
+- [ ] **Step 2-4: red, implement, green + -race.** **Step 5: Commit**
+ `host: steering converger rides the housekeeper`.
+
+---
+
+### Task 7: IAM + attach guidance
+
+**Files:**
+- Modify: `infra/modules/region-host/variables.tf` + `main.tf` (new
+ `steering_zone_ids` list var, same Z-id validation as
+ `route53_zone_ids`; policy: `route53:ChangeResourceRecordSets` +
+ `route53:ListResourceRecordSets` scoped to those zones, plus
+ `route53:CreateHealthCheck`/`DeleteHealthCheck`/`UpdateHealthCheck`/
+ `ListHealthChecks`/`ChangeTagsForResource`/`ListTagsForResource`
+ (health checks are not zone-scoped — condition on the
+ `carlos-steering` tag where the API supports it, and say plainly in a
+ comment where it does not))
+- Modify: `internal/console/apidomains.go` (attach guidance string:
+ custom domains gain the CNAME-to-`edge.<apps-domain>` steering option
+ beside the A-record guidance; apexes keep A-records — the spec's
+ honesty rule, quoted)
+- Test: guidance string test; `tofu validate`-style check is manual
+ (module has no test harness — state it)
+
+- [ ] **Step 1-3: implement + guidance test + gate.** **Step 4: Commit**
+ `infra+console: steering IAM surface + custom-domain guidance`.
+
+---
+
+### Task 8: Bookkeeping + rollout notes
+
+**Files:**
+- Modify: spec (Part 4 rows → implemented-dormant with commits; the
+ 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
+ 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
+ points; the stage-5 checklist from plan 2 consumed here (edges files
+ present per armed box FIRST); the egress ruling request stays open
+ (#-ref); Paul's applies rule (sessions blocked on tofu apply —
+ 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`.
+
+## Rollout notes (filled by Task 8 at execution)
+
+- (filled at execution)