carlos / platform Public

docs: nearest-region spec rev 4, after Astra's round 3

All six round-3 findings verified and accepted, marked "(codex R3-N)".

Three are faults in the existing steering converger, and they would bite
on arming #169 with or without this spec, because static platform hosts
already carry steering "latency": an empty non-terminal shadows the
single *.oncarlos.com wildcard for unsteered siblings; one latency record
per box is invalid when two boxes share an AWS region; over-limit
withdrawals refuse every pass. Three rounds in a row found new faults in
DNS steering, so rev 4 stops patching examples. Phase L moves to a
follow-on spec with six stated invariants (continuity, validity grouped
by AWS region, serviceability, withdrawal, dormancy, authority) and a
generated-fleet simulation against an RFC 4592 resolver model. This spec
writes no stamp, and arming #169 is gated on that spec. R6 stays binding:
sequenced, not dropped.

Also: edges-file eligibility is computed from the reconciler's own parsed
config (CARLOS_POOLS=,tito publishes false); every non-null guess,
including the home fallback, passes the normalized policy predicate;
the section-4 summary now says app-derived values need positive
eligibility. One sequencing question remains for Paul.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 826da7a46baa809bf60e29cb93d306c8d48aee68 parent 0e3aeb3
1 file changed, +271 −251
  • docs/superpowers/specs/2026-10-01-nearest-region-default-design.md +271 −251
diff --git a/docs/superpowers/specs/2026-10-01-nearest-region-default-design.md b/docs/superpowers/specs/2026-10-01-nearest-region-default-design.md
index 9dcda01c6..c29e1ed06 100644
--- a/docs/superpowers/specs/2026-10-01-nearest-region-default-design.md
+++ b/docs/superpowers/specs/2026-10-01-nearest-region-default-design.md
@@ -1,17 +1,21 @@
# Nearest region by default: each app picks its region at creation
**Date:** 2026-10-01
-**Revision:** 3.
+**Revision:** 4.
- Rev 1 (7fbc9479) went to Astra (GPT-6, via codex), and the findings are in
`CODEX-FINDINGS-NEAREST-REGION-R1.md`.
- Rev 2 (bd4ed0c4) went back, and the findings are in
`CODEX-FINDINGS-NEAREST-REGION-R2.md`.
-- Every finding of both rounds was checked against the code and accepted.
- Changes are marked "(codex N)" for round 1 and "(codex R2-N)" for round 2,
- and §21 records each disposition.
-- Rev 3 also folds in Paul's answers to rev 2's questions as rulings R5 to R7.
- R6 brings latency routing into scope (§13).
+- Rev 3 (8e59f598) went back again, and the findings are in
+ `CODEX-FINDINGS-NEAREST-REGION-R3.md`.
+- Every finding of all three rounds was checked against the code and
+ accepted. Changes are marked "(codex N)", "(codex R2-N)" and
+ "(codex R3-N)", and §21 records each disposition.
+- Rev 3 folded in Paul's answers to rev 2's questions as rulings R5 to R7.
+- **Rev 4 moves latency routing (R6) into a follow-on spec**, because the
+ existing steering converger is not safe to arm (§13). That spec gets
+ invariants to meet, and placement ships first.
**Status:** DRAFT for review. The rulings in §3 are Paul's and are not
reopened here. Everything marked *(decision)* is this spec's and open to
@@ -57,7 +61,10 @@ Every create path ends at one door, and that door's default is the home box:
And nothing is steered. Latency routing exists (`internal/steering`), but it
is armed nowhere, because no deployment sets `CARLOS_STEERING_ZONE_ID`
(amadan #169). Every instance host therefore resolves through the
-deployment's static DNS.
+deployment's static DNS. On the flagship that is one `*.oncarlos.com` record
+pointing at the Ireland EIP, served by DNSimple. The Route53 copy of the zone
+is staged and is not yet authoritative (carloku-infrastructure `dns.tf` and
+`dns53.tf`).
What exists to build on:
@@ -85,20 +92,29 @@ Goals:
- A new app gets a region at creation, preselected from a guess of where its
creator is. Its new instances start there unless told otherwise.
- A new instance placed outside the home is reached through the nearest edge,
- not through the home's (R6, §13).
+ not through the home's (R6). The follow-on spec for Phase L delivers this
+ goal, against the invariants in §13.
- The guess is generic: any deployment, any set of AWS regions.
- The guess can never name a region the create door would refuse, or one no
box would mint (codex 1, codex R2-1).
- Existing apps, older CLIs and external callers that send nothing behave
exactly as today.
-**Done** means that new apps get regions, and new non-home instances get
-latency routing once #169 is armed (R5). Existing apps keep their region.
+**Done** has two stages:
+
+- **This spec** is done when new apps get regions and new instances follow
+ them.
+- **The work as a whole** (R5, R6) is done when the follow-on spec has made
+ latency routing safe to arm, and #169 is armed.
+
+Existing apps keep their region.
Relieving Ireland of the instances it already holds is the instance-move
work (#175/#331), not this spec.
Non-goals, each with a follow-up in §20:
+- Arming DNS steering and changing the converger. This is Phase L, the
+ follow-on spec (§13).
- Moving existing instances.
- Changing an existing app's region (R5).
- A browser instance-create form.
@@ -116,7 +132,7 @@ Non-goals, each with a follow-up in §20:
| R3 | Guess **timezone first**. Fall back to IP geolocation when the timezone is UTC, `Etc/*` or otherwise unmappable. | §6 |
| R4 | The CLI guesses too. `carlos deploy` creating an app sends its timezone, the console resolves it, and the CLI prints one line ("Region: UK · London (from your timezone). Use --region to choose another."). No prompt. `--region` overrides everywhere. `carlos instances create` defaults to the app's region. | §11 |
| R5 | **Done = new apps only, plus moves.** Existing apps keep their region. Relieving Ireland comes from instance moves. An owner-only verb to change an existing app's region is out of scope (a follow-up). | §2, §20 |
-| R6 | **Latency routing is in scope** for non-home instances, not placement alone. | §13 |
+| R6 | **Latency routing is in scope** for non-home instances, not placement alone. | §13: its invariants and gates, delivered by the follow-on spec |
| R7 | **The rollback floor is a runbook rule.** It is documented for release notes and the roll recipe, with no outside guard. | §5.7, §17 |
| D1 | The app region is stored console-side and set once at creation. The create door fills an empty `req.Region` from it **before** normalization, the allow-list and the armed checks. Existing apps are backfilled to the home, so behaviour does not change. | §5, §7 |
| D2 | The guess works in coordinates: timezone to a point, IP to a point through an operator-supplied MaxMind-format `.mmdb` (`CONSOLE_GEOIP_DB`), then the great-circle nearest eligible region. Home when everything fails. | §6 |
@@ -137,12 +153,13 @@ finding the great-circle nearest region whose boxes have positively said
they can mint an ordinary instance.
From then on, an instance create that names no region gets the app's region,
-and that value meets every check an explicit one meets. A non-app-derived
-value additionally needs that positive eligibility.
+and that value meets every check an explicit one meets. An **app-derived**
+value additionally needs that positive eligibility, while an explicit one
+keeps today's gate (codex R3-6).
-A new socket-backed instance outside the home is stamped for latency routing
-at mint, exactly as static platform hosts already are. That stamp is inert
-until the deployment arms steering (#169).
+Latency routing for non-home instances (R6) is specified as invariants
+(§13) and delivered by a follow-on spec. Until that lands, this spec writes
+nothing to DNS and stamps nothing.
Existing rows read `""`, which is what they have always meant. Nothing writes
a non-home app region until the operator turns the feature on, and the
@@ -338,6 +355,14 @@ additive fields** in `edgesFile` (`internal/host/edgesfile.go:155-176`):
- `serves_shared_pool`: `poolServed(cfg.Pools, "")`;
- `accepts_inbound`: `cfg.Inbound != InboundNone`.
+**Both are computed from the very `provision.ReconcileConfig` the reconcile
+pass uses**, which `provisionTickConfig` builds (`internal/host/agent.go:2030`).
+They are never re-parsed from the environment (codex R3-3). That parser
+drops empty elements, so `CARLOS_POOLS=,tito` is `["tito"]` and does **not**
+serve the shared pool. The box publishes `false` for it. A box that should
+serve both the shared pool and a named one needs a parser change with its
+own tenancy tests, which is not part of this spec.
+
They follow `hibernation_configured`'s precedent: not `omitempty`, so absent
means "unknown" and false means "no". This is the one box-side change in this
spec. It is purely additive, and old readers ignore it.
@@ -374,8 +399,8 @@ Candidate rules:
- **Read failure** (codex R2-2): the listing failing is distinct from "no
file publishes a placement". `readEdgesFiles` (`instancesplacement.go:83`)
swallows the first today. A new `readEdgesFilesStrict` returns the error.
- Candidates built after a failed read are the home alone, and the door
- treats the failure as unconfirmed (§7).
+ Candidates built after a failed read are the home alone, if the policy
+ admits it (§6.4), and the door treats the failure as unconfirmed (§7).
### 6.2 Points
@@ -468,11 +493,20 @@ else IP gives a point → nearest → "ip"
else home → "default"
```
-If the home has no point, the result is the home with `default`, whatever the
-inputs. There is no guess (`"guess": null`) when:
+**The guess invariant** (codex R3-4): every non-null guess, including every
+fallback to the home, passes the same normalized policy predicate the door
+applies (`instanceRegionAllowed` on the `homeRegionIn` form). The steps run in
+this order:
-- the policy is pooled or unreadable, or
-- the allow-list excludes the home and no nearest exists.
+1. Filter candidates by policy (§6.1).
+2. Pick the nearest among the filtered candidates that have a point.
+3. If the home has no point, or nothing maps, the fallback is the home, **but
+ only if the home is itself a filtered candidate**.
+4. Otherwise the answer is `"guess": null`.
+
+So a manifest that allows only `uk`, with the home's coordinates missing,
+gives `uk` when the hint maps and `null` when it does not, never the home.
+There is also no guess when the policy is pooled or unreadable.
### 6.5 Logged, and not logged (codex 8)
@@ -554,9 +588,6 @@ refusal before anyone tries (codex 7).
*(decision)* **A defaulted home whose region is in its own `replicas` set is
refused** (C26). Explicit requests keep today's behaviour.
-**The latency stamp** (R6) is set beside the static default at `:936`; §13
-gives the full rule.
-
**Response.** `apiRecordView` (`apiinstances.go:535`) gains two fields that
sit outside the record, like `PrivateStoreBucket`:
@@ -593,7 +624,7 @@ and both are stored (§12).
| path | file:line | today | change |
| --- | --- | --- | --- |
-| The door | `handleAPIInstanceCreate`, `apiinstances.go:608`; region at `:772` | `""` means the home | the fill and eligibility (§7); the latency stamp (§13) |
+| The door | `handleAPIInstanceCreate`, `apiinstances.go:608`; region at `:772` | `""` means the home | the fill and eligibility (§7) |
| `carlos instances create` | `instances_cli.go:322` | sends `--region` or `""` | help text; prints `region_name` (§11) |
| `carlos canary` | `canary.go:407` | sends `""` | the door fills the app's region; a new `--region`, with the existing-canary rule (§11) |
| `carlos deploy` static host | `mintPlatformHost`, `deploy.go:858` | static, `""` | none |
@@ -765,17 +796,18 @@ command.
is explicit: **fleet placement, then the request's region, then the app's
region, then the home.** A placed app's region is stored, stays dormant,
and is reported separately from its placement (§9.3).
-- **Pools** (`Manifest.Pool`). There is no default fill, no guess and no
- latency stamp, and `app_default` reports `pooled`. An explicit `--region`
+- **Pools** (`Manifest.Pool`). There is no default fill and no guess, and
+ `app_default` reports `pooled`. An explicit `--region`
behaves as today.
- **Pool-restricted and no-inbound boxes** (codex R2-1) are never eligible
for an automatic choice (§6.1). Explicit requests keep today's gate.
-- **Replicas (#162).** A `role: replica` create is never filled or stamped. A
+- **Replicas (#162).** A `role: replica` create is never filled. A
defaulted home inside its own `replicas` set is refused (§7).
- **Moves (#175/#331).** A move rewrites one record's `region` at its cut.
- **It never touches the app's region**, and it keeps the record's
- `Steering` field (§13).
-- **Static.** Not filled; it already carries its own latency default.
+ **It never touches the app's region**. Steering for moved instances is the
+ follow-on spec's, derived from region (§13.1).
+- **Static.** Not filled. Its existing latency default is subject to the
+ §13.3 gate like everything else steered.
- **Private storage (#412).** Unaffected.
- **Sidecars.** Unchanged. Their door has no armed gate, so following the app
region needs its own design (§20).
@@ -784,189 +816,143 @@ command.
the home today. This change keeps that and reinterprets no manifest. The
README documents it, and rollout audits production manifests first (§17).
-## 13. Latency routing (R6)
-
-### 13.1 What already exists, and why no new subsystem
-
-The steering converger is built and tested.
-
-- **Where it runs.** It runs on whichever platform box holds the housekeeper
- lease (`internal/host/steering_loop.go:204`).
-- **What it reads.** It reads every bucket record with a non-empty
- `Steering` field (`steeredRoutesFromRecords`, `steering_loop.go:99`), never
- a box's registry.
-- **What it builds.** It folds those hosts × armed edges into Route53 latency
- A records (`desiredState`, `internal/steering/steering.go:349`). An edge is
- armed when it publishes an EIP and an AWS-shaped region (`armedEdges`).
-- **What it writes.** It converges the zone to that set (`converge`,
- `internal/steering/route53.go:152`), with one tagged health check per EIP.
-- **Its guards.** An empty-set refusal, a shrink limit, conflict skipping, and
- fail-closed edges reads.
-
-Steering is **any-edge**. Every armed edge gets a record for every steered
-host, and a visitor lands on the nearest healthy edge, which serves locally
-or backhauls to the record's home box over the existing hop
-(`internal/edge/homemap.go`). The only exclusion is #162's replica mark.
-
-So latency routing for a non-home instance needs two things, and neither is
-a subsystem:
-
-1. a stamp on its record at mint;
-2. the deployment arming steering, which is #169.
-
-Phase L below is that pair. It is shippable separately and has its own gate.
-Placement (everything else in this spec) does not wait for it.
-
-### 13.2 Which hosts
-
-**(decision)** At mint, the door sets `rec.Steering = "latency"` when **all**
-of these hold:
-
-| condition | why |
-| --- | --- |
-| `rec.Region != ""` after normalization | R6 is for non-home instances. A home instance keeps today's DNS. |
-| `placed == ""` | Fleet boxes are not steering edges. |
-| `rec.Role == ""` | A replica is served under the home's steered host (#162), never its own. |
-| `rec.DerivesPaths()` | A socket-backed instance, the class this spec places. |
-| `platformNamespace` | `steeredHosts` only writes names under the apps domain (`steering.go` `inAppsZone`). A custom domain steers through its owner's CNAME to `edge.<apps-domain>`. |
-| `rec.Pool == ""` | The pool rail: the PATCH door refuses steering on a pooled record (`apiinstances.go:1396`), and the fold excludes it again. |
-| the request did not name `steering` | The create body has no steering field today, so this is always true at the door. `carlos steering` is the later override. |
-
-The rule applies to every new non-home instance, whether the region was
-explicit or app-derived, because the record's region is what decides where
-the process lives.
-
-The host steered is **the instance's own host**, the record's `Host`
-(`<label>.<app>.<sqid>.<apps-domain>`). There is no separate account host:
-under #162 the "account host" is the home record's own host, which this rule
-covers when that home is non-home. Its replicas stay excluded by the existing
-replica mark. Custom-domain aliases of the instance reach the same edges
-through `edge.<apps-domain>`, which the converger mints once armed.
-
-This makes, on purpose, the trade the static default's comment declined for
-home-boxed routes (`apiinstances.go:919-921`): "a home-boxed route reaches a
-far edge by backhaul". R6 rules it in for non-home instances. A visitor near
-the instance's region lands on that region's edge and is served locally. A
-visitor near the home lands on the home's edge and is backhauled to the
-instance, which is the path every visitor takes today.
-
-**A value at mint, never a converge**, exactly like the static default:
-nothing re-stamps an existing record. `carlos steering --app x --host y off`
-is the escape hatch.
-
-### 13.3 Written where, and by whom
-
-| what | written by | where |
-| --- | --- | --- |
-| `steering: "latency"` on the record | the console's create door, at mint | `apps/<sqid>/<app>/instances/<host>.json` in the deployment bucket |
-| one latency A record per armed edge for the host, `SetIdentifier` = box label, `Region` = the edge's AWS region | the housekeeper box's steering duty | the zone `CARLOS_STEERING_ZONE_ID` |
-| one health check per EIP, tagged `carlos-steering=<zone>` | the same duty | Route53, shared by every steered name |
-| `edge.<apps-domain>` latency records | the same duty, once armed | the same zone |
-
-Records appear on the next pass (5 minutes plus jitter) and then reach
-resolvers on the record TTL. Until then the host resolves exactly as it does
-today, through the deployment's static DNS, and is served by backhaul. Being
-late is never an outage.
-
-### 13.4 Removal
-
-- **Delete.** The record leaves the bucket, so the host leaves the desired
- set, and the next pass DELETEs its managed records. The shrink limit
- (`route53.go:288`) may spread a mass delete over passes. Health checks are
- per EIP and are collected only when nothing references them.
-- **Move.** A move changes `region` and keeps `Steering`. Because steering is
- any-edge, the record set does not change, and visitors keep landing on the
- nearest edge, which now backhauls to the new home. A move *to* the home
- leaves the stamp, which is harmless. A move *from* the home does not add
- one: moves are #175's code, and the follow-up is in §20. Until then the
- move runbook ends with `carlos steering --app <a> --host <h> latency`.
-- **Opt-out.** `carlos steering ... off` clears the field, and the next pass
- removes the records.
-
-### 13.5 When steering is not armed
-
-The stamp is inert, as it is today for every static platform host:
-
-- the duty logs that the zone is unset and it is dormant, reads nothing, and
- calls no AWS API
- (`steering_loop.go:219`);
-- no request fails;
-- placement works.
-
-The deployment is then **placement-only**: a UK instance is reached through
-whatever the static DNS names, and backhauled. Nothing in this spec returns
-an error because steering is unarmed.
-
-### 13.6 The infrastructure prerequisite (#169)
-
-Arming steering is production infrastructure work. It needs scoped approval
-under AGENTS.md before anyone applies it. The plan to approve:
-
-1. **Read first.**
- - Which hosted zone holds the flagship's apps domain.
- - What the apps-domain wildcard resolves to today.
- - How many bucket records already carry `steering: "latency"`. Every
- static platform host minted since the static-HA default does, and
- arming steers them all.
- - Whether any steered name collides with an existing record in the zone.
- The converger skips and logs conflicted names, so check before, not
- after.
-2. **`carlosframework/carloku-infrastructure`, root module.** Pass the zone
- to every platform box's `region-host` module:
- - `steering_zone_ids = [<apps zone id>]`;
- - `steering_record_names = ["*.<apps-domain>"]`.
-
- This grants the IAM policy at `infra/modules/region-host/main.tf:281`.
- Without the name bound, the role can write any A record in the zone, the
- apex included (`variables.tf:153`).
-3. **Each platform box's `/etc/carlos/host.env`** (user data in the same
- repo). Set `CARLOS_STEERING_ZONE_ID=<zone id>` and confirm
- `PLATFORM_APPS_DOMAIN`. Restart the agent, because the loop reads the env
- once at arm.
-4. **Cost.** One Route53 health check per EIP, plus latency-record queries.
- The approval request states both.
-5. **Cleanup and disarm.** Unsetting the env makes the duty dormant but
- leaves the records it wrote. A clean disarm deletes the managed latency
- records and the health checks tagged `carlos-steering=<zone>` by hand.
- That is the cleanup step in the approval request.
-6. **Verification**, which is §13.7.
-
-### 13.7 Measuring latency, before and after
-
-**Test instances.** A test app with one instance in each armed region, plus
-one at the home, created through the API. Creating instances is ordinary API
-use, not infrastructure.
-
-**DNS answers**, read-only, from anywhere. Route53 honours EDNS client
-subnet, so this works without being in each region:
-
-```
-dig +short +subnet=<client prefix>/24 @<zone nameserver> <instance host> A
-```
-
-Run it for a UK, an Irish and an Australian client prefix. Before arming, the
-answer is the static DNS answer for every prefix. After, each prefix gets its
-own region's edge EIP.
-
-**The request path**, from a vantage in each region: each platform box, run
-read-only as platform work, and the operator's own machine.
-
-```
-curl -s -o /dev/null -w '%{time_connect} %{time_appconnect} %{time_starttransfer}\n' https://<instance host>/
-```
-
-Run 30 requests per host per vantage and record the median and the p90 of
-connect and TTFB. Take a baseline after Phase P and before arming, and
-measure again after arming, with the same hosts and vantages.
-
-**The result to record** in #169 and in this spec's discussion, as numbers
-rather than a claim:
-
-- A UK vantage's connect time to a UK instance should fall to roughly the
- in-region round trip.
-- Its TTFB should lose the home backhaul.
-- A home vantage's numbers to the same instance should be unchanged within
- noise.
+## 13. Latency routing (R6): the requirements Phase L must meet
+
+### 13.1 Why this is now a separate spec (codex R3-1, R3-2, R3-5)
+
+Rev 3 treated latency routing as two small things: a stamp on each non-home
+record at mint, and arming the existing converger (#169). Round 3 showed that
+the converger itself is not ready to arm. Each of these three faults was
+checked against the code:
+
+- **Arming can break DNS for names nobody steered** (codex R3-1). The flagship
+ answers every tenant name with one `*.oncarlos.com` record pointing at the
+ Ireland EIP (carloku-infrastructure `dns53.tf` and `dns.tf`; the platform
+ README documents the same single wildcard at `README.md:442`). The converger
+ writes A records at exact names (`internal/steering/steering.go:369`, `:386`).
+ One latency record at `new.bap.oncarlos.com` creates the empty non-terminal
+ `bap.oncarlos.com`. From then on, `old.bap.oncarlos.com` finds `bap` as its
+ closest encloser, looks for `*.bap.oncarlos.com`, which does not exist, and
+ no longer matches `*.oncarlos.com` (RFC 4592 §3.3.1). Every unsteered
+ sibling under that account stops resolving. Static platform hosts have been
+ stamped `latency` at mint for weeks (`apiinstances.go:936`), so **arming #169
+ as the code stands would do this to every account with a static site.** It
+ would happen with or without this spec.
+- **Two edges in one AWS region produce an invalid record set** (codex R3-2).
+ `desiredRRSet` keys each record by box (`SetIdentifier`) but stamps it with
+ the edge's AWS region (`internal/steering/route53.go:597`). Route53 allows
+ one latency record per region within a name and type. A second London box
+ makes every batch fail, on every pass. This spec allows several placement
+ codes in one AWS region (§6.1), so the topology is reachable.
+- **Large withdrawals never complete** (codex R3-5). When a pass would delete
+ more than the shrink limit, it refuses **every** change and returns
+ (`route53.go:207`). The loop's own notes say this repeats forever until an
+ operator steps in (`internal/host/steering_loop.go:154`). Rev 3's claim that
+ deletes "spread over passes" was wrong.
+
+Behind these sits a fourth fact. The flagship's Route53 zone for
+`oncarlos.com` is marked "STAGED, NOT YET AUTHORITATIVE" in `dns53.tf`, and
+DNSimple still serves the live answers (`dns.tf`). A converger writing to a
+zone nobody consults is inert at best.
+
+Three rounds have each found a new fault in this one area. Per the lesson this
+repo has already paid for, the answer is not another patch to an example. It
+is an invariant, a design built to it, and a simulation that checks it.
+That is a spec of its own, about the converger. It is not a section of a
+spec about choosing regions.
+
+**(decision) Phase L moves to a follow-on spec, and placement ships without
+waiting for it.** R6 is unchanged: latency routing for non-home instances is
+still in scope for this work and still binding. It is sequenced, not dropped.
+This spec now does three things for R6:
+
+1. It **writes no stamp.** Rev 3's mint-time `Steering: "latency"` is
+ withdrawn. Every stamped record would join the set of names whose DNS
+ changes when the zone is armed, and that set is exactly the danger above.
+ The follow-on spec should derive "steer this host" from the record's
+ region, not from a stamp: any socket-backed home record with a non-home
+ region, in the shared pool and the platform namespace. That covers
+ instances created before it lands, and instances moved out of the home,
+ with no backfill and no change to the move code.
+2. It **states the invariants** that spec must meet (§13.2).
+3. It **gates arming #169** on that spec (§13.3).
+
+### 13.2 Invariants for the follow-on spec
+
+The DNS model is the authoritative zone for the apps domain, the set of armed
+edges with their AWS regions and health, and the bucket records. Over that
+model, these must hold after every converge pass, armed or not:
+
+- **I1, continuity.** Arming, and every later pass, leaves the answer
+ unchanged for every name that has no steered record set of its own. This
+ includes names that sit beneath an empty non-terminal a steered name
+ creates, nested canaries, names whose steering was turned off, and names
+ whose record was deleted. "The answer" is the result of RFC 4592
+ resolution over the whole zone, not an exact-name lookup.
+- **I2, validity.** For every steered name, the zone holds at most one latency
+ record per AWS region. The record set is a function of the set of eligible
+ edges serving that name, **grouped by AWS region**. Adding, removing or
+ renaming a box changes the answers inside a region's group. It never
+ changes how many latency records the name has for that region. One way to
+ get this is one latency record per region, aliasing a per-region,
+ health-checked set of that region's edges. The spec chooses.
+
+ This groups by region rather than "keyed by edge" because Route53's rule is
+ per region.
+- **I3, serviceability.** Every answer for a steered name is an edge that can
+ serve it: healthy, holding the account's certificate, and able to reach the
+ name's home through the home map. The far edge backhauls; it never
+ answers 404.
+- **I4, withdrawal.** A withdrawal justified by a complete read (a record
+ deleted, steering turned off, a box retired) converges within a bounded
+ number of passes. If it cannot, it raises a named operator alert with a
+ documented procedure. The short-read guard still refuses deletions that a
+ complete read does not justify, but a refusal of deletions never blocks
+ unrelated additions in the same pass.
+- **I5, dormancy.** With no zone configured, nothing is read and nothing is
+ written. This is already true (`steering_loop.go:219`).
+- **I6, authority.** The converger only writes to the zone the apps domain is
+ actually delegated to. It verifies the delegation before its first write,
+ and stays dormant, loudly, when it cannot.
+
+**The proof the follow-on spec must ship:** a property test over generated
+fleets. It covers random edges, random AWS regions including duplicates,
+random steered and unsteered names including nested and sibling names, and
+random sequences of creates, deletes, opt-outs, moves and box retirements.
+
+Each scenario runs through `desiredState` and `converge` against a Route53
+fake. The fake enforces the per-region latency rule, batch atomicity, and an
+RFC 4592 resolver. The test asserts I1 to I5 after every step, at high seed
+counts, and each invariant's assertion is checked by mutation: break the
+code, and watch the test fail. Examples alone have now missed three faults
+in a row.
+
+### 13.3 Gates
+
+- **Do not arm #169** (`CARLOS_STEERING_ZONE_ID`) on any deployment until the
+ follow-on spec has landed and its simulation passes. This holds for the
+ static-site steering that already exists, independently of this spec.
+- The follow-on spec carries the infrastructure plan for scoped approval
+ (AGENTS.md):
+ - the zone and its delegation;
+ - `steering_zone_ids` and `steering_record_names = ["*.<apps-domain>"]` on
+ every platform box's `region-host` module
+ (`infra/modules/region-host/main.tf:281`, `variables.tf:153`);
+ - `CARLOS_STEERING_ZONE_ID` in each box's `host.env`;
+ - cost, cleanup and disarm.
+- It also carries the latency measurement. Rev 3's method stands and moves
+ there: `dig +subnet` against the zone's nameservers, then
+ `curl -w '%{time_connect} %{time_appconnect} %{time_starttransfer}'` from a
+ vantage in each region, 30 runs per host, with medians and p90. Take a
+ baseline after this spec's placement ships and before arming, measure again
+ after, and record the numbers in #169.
+
+Until then, a deployment that runs this spec is **placement-only**. A UK
+instance is reached through the deployment's static DNS (on the flagship, the
+Ireland EIP) and backhauled to the UK box. That costs the visitor about one
+Ireland–London round trip compared with landing in London. It is no worse
+than today for anyone, and it relieves Ireland's disk for every new non-home
+instance.
## 14. Place names
@@ -1005,9 +991,8 @@ than the picker, but it must agree geographically, and the README says so.
| manifest unreadable | The guess endpoint returns 502 (C27). `/regions` answers `policy-unreadable`. |
| switch on, home unnamed | The switch is treated as off; logged once. |
| console binary rolled back below the floor | R7: no-region creates land at the home; roll-forward lists possible discrepancies. |
-| steering unarmed | Placement-only, with no error (§13.5). |
-| steering armed, one edge's region not AWS-shaped | That edge gets no records; logged by `armedEdges`. |
-| a steered name collides with an existing zone record | The converger skips the name and logs it; the host keeps its static DNS. |
+| steering unarmed | Placement-only, with no error. A non-home instance is reached through the static DNS and backhauled (§13.3). |
+| a manifest's allow-list excludes the home, and no hint maps | `"guess": null`, never the home (§6.4). |
| owner adopts an existing app | `""`/`default`, unless `region` is explicit. |
## 16. Test plan
@@ -1040,8 +1025,13 @@ than the picker, but it must agree geographically, and the README says so.
`CARLOS_POOLS` and `CARLOS_INBOUND`, for:
- unset;
- `CARLOS_POOLS=tito`;
- - `CARLOS_POOLS=,tito`, which includes the shared pool;
+ - `CARLOS_POOLS=,tito`, which the parser reads as `["tito"]`, so it
+ publishes `serves_shared_pool: false` (codex R3-3);
- `CARLOS_INBOUND=none`.
+- The published fields are asserted equal to `poolServed` and
+ `cfg.Inbound != InboundNone` over the same `provisionTickConfig` output, for
+ a table of `CARLOS_POOLS` and `CARLOS_INBOUND` spellings. That makes
+ publication and reconciliation one predicate.
- The reader treats absent as unknown and false as no.
- Eligibility needs all three fields true on one reachable box.
- The guess against a named-pool-only box returns no guess for that code.
@@ -1076,21 +1066,27 @@ than the picker, but it must agree geographically, and the README says so.
explicit create is unchanged.
- The switch off with an existing `uk` row: the fill still applies.
-**Latency stamp** (R6):
+**No stamp** (§13.1). A non-home instance created by this spec carries no
+`Steering` field. The record is byte-identical to one created by
+`instances create --region` before this spec.
+
+**The guess invariant** (codex R3-4). A property test over generated
+policies, candidate sets, missing coordinates, unmappable hints and
+edges-read failures asserts two things:
+
+- every non-null guess passes `instanceRegionAllowed(m.Regions,
+ homeRegionIn(code))`;
+- a non-null guess exists whenever some filtered candidate is reachable by
+ the order in §6.4.
-- A non-home, socket-backed, platform-namespace, shared-pool home record is
- stamped `latency`. This holds whether its region was explicit or
- app-derived.
-- Not stamped: a home-region record, a fleet-placed app, a replica, a pooled
- manifest, a custom-domain manifest, and an addr-backed or static record.
- Static keeps its own default.
-- `steeredRoutesFromRecords` picks the stamped record up.
-- `desiredState` builds one record per armed edge for its host.
-- Deleting the record removes the host from the desired set.
-- A move keeps the field.
+Named cases:
-**Pools, end to end.** A pooled manifest gives `guess: null`,
-`app_default.reason: pooled`, and no stamp.
+- an allow-list of only `uk`, with the home's coordinates missing;
+- an unmappable hint against the same list;
+- an edges-read failure against the same list, which gives `null`.
+
+**Pools, end to end.** A pooled manifest gives `guess: null` and
+`app_default.reason: pooled`.
**App create.**
@@ -1168,13 +1164,11 @@ and `ip` appear in none of:
to every box, then check every region's files report all three fields.
Until then, no coded region is eligible (§6.1).
3. **Console release, switch off.** It carries the migration, `internal/geo`,
- the fill, the stamp, the endpoints and the UI. Behaviour is unchanged
- (the stamp only fires on non-home records, which only explicit `--region`
- creates today, and it is inert while steering is unarmed). It can be
+ the fill, the endpoints and the UI. Behaviour is unchanged, and it can be
rolled back freely.
4. **Verify on dev.** Turn the switch on with two eligible regions. Then:
- create an app in the browser, and read its row;
- - create an instance with no region, and read its record and its stamp;
+ - create an instance with no region, and read its record;
- diff `/regions` for an old app against the pre-roll answer.
5. **Flagship.** Roll the console, then turn the switch on. **From here the
binary rollback floor applies (R7).** That release's notes and the roll
@@ -1183,21 +1177,26 @@ and `ip` appear in none of:
6. **CLI release.** It is safe in either order (§9.6).
7. **titogo**, in its own repo, adopts `/regions/guess` and the place names.
-**Phase L: latency (R6)**, gated on Phase P step 3 and on scoped approval:
-
-1. Take the §13.7 baseline on the flagship.
-2. Bring the §13.6 plan for approval, then apply it in carloku-infrastructure.
-3. Watch one converge pass: the records it wrote, and the conflicts it
- logged.
-4. Measure again (§13.7), and record the numbers in #169. If the numbers
- regress, disarm per §13.6 step 5. Placement is unaffected either way.
+**Phase L: latency (R6)** is the follow-on spec's rollout, not this one's
+(§13.3). Its gate is that spec landing with its simulation passing, plus
+scoped approval for the infrastructure. Until then, **no deployment arms
+`CARLOS_STEERING_ZONE_ID`**, including for the static-site steering that
+already exists. Take the latency baseline once Phase P step 5 has run.
Copy review (§19) happens before Phase P step 3 ships any string.
## 18. Questions for Paul
-None. R5 to R7 settled rev 2's three questions, and every other open item has
-a decision in the body or a follow-up in §20.
+1. **Sequencing R6.** Do you agree that placement (Phase P) ships on this
+ spec, and latency routing (Phase L) follows on a spec of its own, built to
+ §13.2's invariants?
+ - The alternative is to hold placement until the converger is fixed.
+ Ireland's disk is the incident, and placement relieves it for new
+ instances without touching DNS.
+ - **Recommendation: split.** R6 stays binding on the follow-on spec. It is
+ sequenced, not dropped.
+
+ This is a sequencing call under R6, which is why it is yours.
## 19. Copy (pending copy review)
@@ -1268,9 +1267,11 @@ The `docs/site/cli.md`, README and release-note additions (§5.7, §11, §12,
- **Changing an existing app's region** (R5). An owner-only verb that affects
future creates only and leaves existing instances alone.
-- **Moves stamping steering.** A cut from the home into a non-home region
- should set `Steering: latency` under §13.2's rule. Until then, the move
- runbook adds `carlos steering … latency`.
+- **Phase L: latency routing (R6)**, on its own spec, meeting §13.2's
+ invariants with a simulation over generated fleets. It derives "steer this
+ host" from the record's region, which also covers moved instances. It
+ carries #169's infrastructure plan, the delegation check for the flagship's
+ `oncarlos.com` zone, and the latency measurement.
- **Pool-aware candidates.** Pool eligibility proven by the target box itself.
- **Sidecars following the app region**, with an armed gate on their door.
- **A browser instance-create form.**
@@ -1307,7 +1308,7 @@ The `docs/site/cli.md`, README and release-note additions (§5.7, §11, §12,
| rev 1 Q | disposition |
| --- | --- |
| Q1 titogo adoption in "done" | superseded by R5: done is new apps plus moves |
-| Q2 placement-only, measure first | superseded by R6: latency is in scope. Measurement is kept (§13.7). |
+| Q2 placement-only, measure first | superseded by R6: latency is in scope. Measurement moves to the follow-on spec (§13.3). |
| Q3 refuse an unavailable default | adopted (§7) |
| Q4 creation-only region | adopted; the change verb is a follow-up (R5, §20) |
| Q5 defer the browser instance form | adopted (§20) |
@@ -1328,5 +1329,24 @@ The `docs/site/cli.md`, README and release-note additions (§5.7, §11, §12,
| rev 2 Q | disposition |
| --- | --- |
| Q1 (b): titogo in "done" | superseded by R5 |
-| Q2 (a): placement-only, measure first | superseded by R6. Measurement is kept as Phase L's gate (§13.7, §17). |
+| Q2 (a): placement-only, measure first | superseded by R6. Measurement moves with Phase L to the follow-on spec (§13.3). |
| Q3 (a): accept the floor | matches R7 |
+
+### Round 3 (rev 3, 8e59f598)
+
+| # | sev | finding | disposition |
+| --- | --- | --- | --- |
+| R3-1 | P1 | Arming per-host steering breaks DNS for unsteered siblings, through empty non-terminals shadowing `*.<apps-domain>` | **accepted.** Verified against `steering.go:369`, `:386` and carloku's single wildcard (`dns53.tf`, `dns.tf`). It already affects static-site steering. Resolved by invariant I1, the gate on arming #169, and the move of Phase L to a follow-on spec (§13). |
+| R3-2 | P1 | One latency record per edge is invalid when two edges share an AWS region | **accepted.** Verified at `route53.go:597`. Resolved by invariant I2, which groups by AWS region, and a Route53 fake that enforces the rule (§13.2). This departs from the coordinator's example wording "keyed by edge, not region", because Route53's constraint is per region. |
+| R3-3 | P1 | The `CARLOS_POOLS=,tito` test would publish shared eligibility the reconciler denies | **accepted.** Verified at `agent.go:2033`. Publication is computed from the same `ReconcileConfig` and predicate, `,tito` publishes false, and a test pins the equality (§6.1, §16). |
+| R3-4 | P2 | Home fallback is not constrained by the allow-list | **accepted.** The guess invariant, with the home fallback only when the home passes the policy, and a property test (§6.4, §16). |
+| R3-5 | P2 | Large DNS withdrawals are refused indefinitely, not spread over passes | **accepted.** Verified at `route53.go:207` and `steering_loop.go:154`. The wrong claim is removed, and invariant I4 makes it the follow-on spec's requirement (§13). |
+| R3-6 | P3 | §4 reverses which requests need positive eligibility | **accepted.** Corrected to app-derived (§4). |
+
+### Astra's round-3 recommendations
+
+| former question | disposition |
+| --- | --- |
+| Q1: apply R5, titogo separate | adopted (§2, §17 step 7) |
+| Q2: implement R6 with the DNS corrections, keep the separate approved Phase L rollout and measurements | adopted in substance. The corrections are larger than a patch, so they move to the follow-on spec with invariants (§13); the sequencing is §18 Q1. |
+| Q3: name the exact floor version before activation; keep region-aware reads when disabling | adopted (§5.6, §5.7, §17 step 5) |