carlos / platform Public

docs: nearest-region spec rev 3, after Astra's round 2 and Paul's R5-R7

Paul's answers become rulings: R5 done is new apps plus moves (changing
an existing app's region is a follow-up), R6 latency routing is in
scope for non-home instances, R7 the rollback floor is a runbook rule.

R6 is folded in as Phase L: no new subsystem, since the steering
converger already exists and is any-edge. The create door stamps
steering "latency" at mint on non-home, socket-backed, shared-pool,
platform-namespace home records, inert until #169 arms
CARLOS_STEERING_ZONE_ID in carloku-infrastructure under scoped approval.
The spec covers removal on delete and move, degradation when unarmed,
and before/after measurement.

All six round-2 findings verified and accepted, marked "(codex R2-N)":
positive per-box eligibility (two additive edges-file fields for the
shared pool and inbound) for automatic choices; app-derived defaults
never ride the dormant gate, and a read failure is distinct; the @auto
sentinel sits outside the code grammar; app_default separates the
stored region from the effective placement; regionReq carries a
request-local IP; the roll-forward list reports possible discrepancies
only. No questions remain for Paul.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8e59f598a2e214c185d7b3d640696a8f099d6a5b parent 16829cf
1 file changed, +970 −631
  • docs/superpowers/specs/2026-10-01-nearest-region-default-design.md +970 −631
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 d213f17fb..9dcda01c6 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,31 @@
# Nearest region by default: each app picks its region at creation
**Date:** 2026-10-01
-**Revision:** 2. Rev 1 (7fbc9479) was reviewed by Astra (GPT-6, via codex):
-`CODEX-FINDINGS-NEAREST-REGION-R1.md`, 4 P1 and 4 P2, verdict not ready for
-planning. Every finding was checked against the code and accepted. Changes
-are marked "(codex N)" in the text, and §19 records each disposition.
-**Status:** DRAFT for review. The rulings in §3 are Paul's (2026-10-01) and
-are not reopened here. Everything marked *(decision)* is this spec's and open
-to review. §17 holds only the questions that need Paul.
-**Discussion:** not yet filed on amadan.
-**Builds on:** #329 (the armed-placement gate), #346 (the home region's name,
-`CONSOLE_HOME_REGION`), the customer-fleets placement column, #162 (replica
-placements), #175/#331 (instance moves).
+**Revision:** 3.
+
+- 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).
+
+**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
+review.
+**Discussion:** not yet filed on amadan. Latency routing depends on amadan
+#169.
+**Builds on:**
+
+- #329, the armed-placement gate
+- #346, the home region's name (`CONSOLE_HOME_REGION`)
+- the customer-fleets placement column
+- #162, replica placements
+- #175/#331, instance moves
+- the multi-region steering converger (`internal/steering`)
## 1. The problem, as found
@@ -24,7 +38,7 @@ Every create path ends at one door, and that door's default is the home box:
- The instance create door, `handleAPIInstanceCreate`
(`internal/console/apiinstances.go:608`), trims `req.Region` and hands it to
- `homeRegionIn` (`apiinstances.go:772`; the function is
+ `homeRegionIn` (`apiinstances.go:772`; the function is at
`internal/console/apiinstancemove.go:136`). The home's name normalizes to
`""`, and `""` is the unconstrained record only the home box mints.
- The new-app page (`internal/console/templates/app_new.html`) asks for a name
@@ -32,25 +46,36 @@ Every create path ends at one door, and that door's default is the home box:
(`templates/app_instances.html:60-78`) offers "Shared fleet" or one of the
account's own fleets, which is a different axis.
- `carlos deploy` creates an app only through `offerCreateApp`
- (`deploy.go:513`, call at `:536`), with no region. Its only instance write
- is the static platform host (`mintPlatformHost`, `deploy.go:850`, create at
- `:858`), which sends `""`.
+ (`deploy.go:513`, call at `:536`), and sends no region. Its only instance
+ write is the static platform host (`mintPlatformHost`, `deploy.go:850`,
+ create at `:858`), which sends `""`.
- `carlos canary` creates its host at `canary.go:407` with region `""`.
- Only `carlos instances create --region` (`instances_cli.go:293`, sent at
`:322`) chooses, and only when a person remembers to. External signup paths
(titogo, Managed Jelly) hold instance-role tokens and send no region.
-What exists to build on: placement codes are bare (`ie`, `uk`, `au`),
-`CARLOS_REGION` verbatim. The only place name anywhere is `STATUS_BOX_REGION`,
-operator free text for the public status page (`internal/host/agent.go:88-92`).
-Every box publishes its AWS region and its placement code in its edges file
-(`internal/host/edgesfile.go:155-176`; the region comes from `edgesFileRegion`,
-`:537`, which honours a `CARLOS_AWS_REGION` override), and the console already
-reads both (`edgesFilePlacementView`, `internal/console/instancesplacement.go:35-55`;
-`servedBy.Region`, `:25-28`). `placements` and `armedPlacements`
-(`instancesplacement.go:173`, `:207`) know which codes can take an instance.
-`GET /api/cli/apps/{account}/{app}/regions` (`internal/console/apiregions.go:31-55`)
-returns codes, `armed` and `"default": HomeRegion`, and reads no manifest.
+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.
+
+What exists to build on:
+
+- **Placement codes** are bare (`ie`, `uk`, `au`): `CARLOS_REGION` verbatim.
+- **Place names**: the only one anywhere is `STATUS_BOX_REGION`, operator free
+ text for the public status page (`internal/host/agent.go:88-92`).
+- **The edges file**: every box publishes its AWS region and its placement
+ code there (`internal/host/edgesfile.go:155-176`). The region comes from
+ `edgesFileRegion` (`:537`), which honours a `CARLOS_AWS_REGION` override.
+- **What the console already reads**: both of those, through
+ `edgesFilePlacementView` (`internal/console/instancesplacement.go:35-55`)
+ and `servedBy.Region` (`:25-28`).
+- **Capability**: `placements` and `armedPlacements`
+ (`instancesplacement.go:173`, `:207`) say which codes can host.
+- **The regions endpoint**: `GET /api/cli/apps/{account}/{app}/regions`
+ (`internal/console/apiregions.go:31-55`) returns codes, `armed` and
+ `"default": HomeRegion`, and reads no manifest.
+
There is no geolocation of any kind.
## 2. Goals and non-goals
@@ -58,54 +83,70 @@ There is no geolocation of any kind.
Goals:
- A new app gets a region at creation, preselected from a guess of where its
- creator is, and its new instances start there unless told otherwise.
+ 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).
- 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 the
- reconciler would never mint (codex 1).
+- 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.
-Non-goals:
+**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.
+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:
-- Moving existing instances (#175/#331).
+- Moving existing instances.
+- Changing an existing app's region (R5).
+- A browser instance-create form.
+- Sidecars following the app's region.
+- Pool-aware candidates.
- Box sizing and disk alerts.
- An account-level default (R1).
-- Changing an app's region after creation. This release is creation-only. A
- later owner-only verb would change future creates only and leave existing
- instances alone.
-- A browser instance-create form. None exists today (§10), and the CLI and
- API already reach the shared door.
-- Sidecars following the app's region. Their door has no armed gate, so that
- needs its own design (§12).
-- Pool-aware candidates. Pooled apps get no guess and no default (§6.1, §7).
-- Any change to DNS steering (§17 Q2), and any box-side change.
## 3. Rulings (Paul, 2026-10-01)
| # | Ruling | Where this spec spends it |
| --- | --- | --- |
-| R1 | Region is both latency and placement, chosen **per app**. Each app picks its region at creation, preselected from a location guess. No account-level default. | §5, §8.1 |
-| R2 | The app's region is the **default** for new instances, overridable per instance (picker or `--region`), subject to the manifest `Regions` allow-list and the armed/hosting check. | §7 |
-| R3 | Guess **timezone first**; 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, 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 |
-| D1 | App region stored console-side, set once at creation. The create door fills an empty `req.Region` from it **before** normalization, allow-list and armed checks. Boxes unchanged. Existing apps backfilled to the home: zero behaviour change. | §5, §7 |
-| D2 | Guess in coordinates: timezone to a point, IP to a point via an operator-supplied MaxMind-format `.mmdb` (`CONSOLE_GEOIP_DB`), then great-circle nearest **armed** region. Home when everything fails. | §6 |
-| D3 | Surfaces: new-app picker; external pickers get names and the guess; CLI as R4. | §9, §10, §11 |
-| D4 | Place names from one platform-shipped AWS-region table, used everywhere, reconciled with `STATUS_BOX_REGION`. | §13 |
+| R1 | Region is both latency and placement, chosen **per app**. Each app picks its region at creation, preselected from a location guess. No account-level default. | §5, §8.1, §13 |
+| R2 | The app's region is the **default** for new instances. It can be overridden per instance (picker or `--region`), subject to the manifest `Regions` allow-list and the armed/hosting check. | §7 |
+| 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 |
+| 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 |
+| D3 | Surfaces: a new-app picker; names and the guess for external pickers; the CLI as R4. | §9, §10, §11 |
+| D4 | Place names come from one platform-shipped AWS-region table, used everywhere and reconciled with `STATUS_BOX_REGION`. | §14 |
## 4. The shape, in one paragraph
-The console's `apps` row gains `region`, in the record's own vocabulary (`""`
-is the home), and `region_source`, one word saying how it was picked. Both
-app-create doors resolve a region before the row is written: an explicit
-choice, else the creator's timezone, else the request's IP through the
-optional GeoIP database, else the home. "Resolve" means the great-circle
-nearest region that can host an instance right now. 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. 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 console refuses to turn it on without a named home
-(§5.6).
+The console's `apps` row gains two columns:
+
+- `region`, in the record's own vocabulary, where `""` is the home;
+- `region_source`, one word saying how it was picked.
+
+Both app-create doors resolve a region before the row is written. The order
+is an explicit choice, then the creator's timezone, then the request's IP
+(through the optional GeoIP database), then the home. Resolving means
+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.
+
+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).
+
+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
+console will not turn it on without a named home.
## 5. Data model
@@ -120,12 +161,12 @@ ALTER TABLE apps ADD COLUMN region_source TEXT NOT NULL DEFAULT '';
```
- `region` is a placement code in the **record vocabulary**: `""` is the
- deployment's home, exactly as on a `provision.Record` (#346). A choice of the
- home's name is normalized with `homeRegionIn` before storing; displays use
- `homeRegionOut` (`apiinstancemove.go:143`).
+ deployment's home, exactly as on a `provision.Record` (#346). A choice of
+ the home's name is normalized with `homeRegionIn` before storing, and
+ displays use `homeRegionOut` (`apiinstancemove.go:143`).
- `region_source` is `chosen`, `timezone`, `ip`, `default`, or `""` (the row
- predates this feature). It is a label for people and logs; nothing branches
- on it.
+ predates this feature). It is a label for people and logs, and nothing
+ branches on it.
**Nothing else is stored**: not the timezone, not the IP, not a coordinate.
@@ -134,23 +175,27 @@ ALTER TABLE apps ADD COLUMN region_source TEXT NOT NULL DEFAULT '';
`migrateAppsKey` (`store.go:472`) rebuilds `apps` with a fixed column list
(`:487-505`) and runs **after** the ALTER list. On a database not yet rebuilt,
the new columns would be added and then dropped by the rebuild's
-`INSERT … SELECT`, and every later read of `region` would fail. **Both columns
-go into `apps_new`'s DDL and the INSERT's column list in the same change**,
-the rule the comment at `:499-503` already records for `placement`,
-`control_plane` and `build_probe`.
+`INSERT … SELECT`, and every later read of `region` would fail.
+
+**Both columns go into `apps_new`'s DDL and the INSERT's column list in the
+same change.** This is the rule the comment at `:499-503` already records for
+`placement`, `control_plane` and `build_probe`.
### 5.3 Written with the row
-**(decision) The region rides the INSERT that claims the name.** A new
-`CreateAppInRegion(accountID, app, createdBy, region, source, now)` carries
-both columns; `CreateAppIn` (`store.go:1349`) becomes a call to it with
-`("", "")`, so `CreateApp` (`:1342`) is unchanged. `claimApp`
-(`internal/console/server.go:3466`) gains a `regionReq` parameter (§5.5).
+**(decision) The region rides the INSERT that claims the name.**
+
+- A new `CreateAppInRegion(accountID, app, createdBy, region, source, now)`
+ carries both columns.
+- `CreateAppIn` (`store.go:1349`) becomes a call to it with `("", "")`, so
+ `CreateApp` (`:1342`) is unchanged.
+- `claimApp` (`internal/console/server.go:3466`) gains a `regionReq`
+ parameter (§5.5).
This departs from the placement precedent on purpose. `SetAppPlacement` after
-`CreateAppIn` (`server.go:3550-3567`) is best-effort because `carlos apps
-place` repairs it. An app region has no repair verb, so a failed second write
-would leave the app at the home for good, silently.
+`CreateAppIn` (`server.go:3550-3567`) is best-effort, because `carlos apps
+place` can repair it. An app region has no repair verb (R5), so a failed
+second write would leave the app at the home for good, silently.
Rename (`store.go:1791`) and transfer (`:1709`) update the same row, so the
region survives both. The legacy owners backfill (`:665`) inserts defaults,
@@ -160,44 +205,67 @@ which mean the home.
**(decision) The column default is the backfill.** Every existing row reads
`""`/`""`. `""` is the record vocabulary's home, so the door's fill (§7) turns
-`""` into `""` and nothing changes, byte for byte. Writing the home's code
-instead would gain nothing (`homeRegionIn` normalizes it back to `""`), would
-be impossible where `CONSOLE_HOME_REGION` is unset, and would freeze the home
-as of migration day, where `""` follows it as records already do. It is
-idempotent by construction: the ALTERs swallow "duplicate column", and there
-is no one-shot `PRAGMA user_version` step.
+`""` into `""` and nothing changes, byte for byte.
-### 5.5 Explicit choice versus guess, and adoption (codex 2)
+Writing the home's code instead would be worse on every count:
-The request to `claimApp` is a `regionReq{explicit string; tz string;
-person bool}`, not a resolved region. **Only `explicit` is a person's
-choice.** The guess is resolved inside `claimApp`, after it knows whether
-this is an adoption, so no caller can hand it a guess dressed as a choice.
+- It would gain nothing, because `homeRegionIn` normalizes it back to `""`.
+- It would be impossible where `CONSOLE_HOME_REGION` is unset.
+- It would freeze the home as of migration day, where `""` follows the home
+ as records already do.
-**(decision) Adoption never changes an existing app's region by guessing.**
-When an owner claims a name whose objects already exist (`adoptingExistingApp`,
-`server.go:3502`, `:3545`), the app probably has instances at the home. An
-adoption stores `""` with source `default` unless `explicit` is set. A guess,
-however it was computed and whatever the form displayed, is discarded.
+The backfill is idempotent by construction: the ALTERs swallow "duplicate
+column", and there is no one-shot `PRAGMA user_version` step.
+
+### 5.5 Explicit choice versus guess, and adoption (codex 2, codex R2-3, codex R2-5)
-The browser form keeps the two apart without trusting any label: the
-preselected option carries the value `auto`, and only a region code is
-`explicit` (§10). The CLI sends `region` only for `--region`, and `timezone`
-separately (§9.1).
+`claimApp` receives a request-local struct, not a resolved region:
+
+```go
+type regionReq struct {
+ explicit string // a person's choice: a code or the home's name; "" = none
+ tz string // a hint
+ ip net.IP // from s.clientIP, set only for a person's request
+}
+```
+
+**Only `explicit` is a choice.** The guess is resolved inside `claimApp`,
+after it knows whether this is an adoption, so no caller can pass a guess off
+as a choice.
+
+`ip` comes from `s.clientIP` (`internal/console/authcli.go:876`). `claimApp`
+has no `*http.Request`, and its `addr` argument is the audit actor
+(`apicli.go:349`), not a network address (codex R2-5). Both callers fill `ip`
+before the call. It lives only for that call, and is never logged or stored.
+
+**(decision) Adoption never changes an existing app's region by guessing.**
+An owner claiming a name whose objects already exist (`adoptingExistingApp`,
+`server.go:3502`, `:3545`) is restoring an app that probably has instances at
+the home. An adoption stores `""` with source `default` unless `explicit` is
+set. A guess, however it was computed and whatever the form displayed, is
+discarded.
+
+The browser form keeps the two apart without trusting any label. The
+preselected option's value is `@auto`, which is outside the placement-code
+grammar (`^[a-z0-9-]{0,16}$`, `provision.go:688`). A deployment with a
+region, or a home, named `auto` therefore still works (codex R2-3). Only a
+region code is `explicit` (§10). The CLI sends `region` only for `--region`,
+and sends `timezone` separately (§9.1).
### 5.6 The switch and the named home (codex 3, codex 4)
-**(decision) Non-home app regions are written only when both hold:**
+**(decision) Non-home app regions are written only when both of these hold:**
-- `CONSOLE_APP_REGIONS=on` (new, default off), and
+- `CONSOLE_APP_REGIONS=on` (new; off by default);
- `CONSOLE_HOME_REGION` is set.
-If the switch is on and the home is unnamed, the console logs this once at
-startup and behaves as if the switch were off. A warning alone would not do.
-With an unnamed home, `""` is the only way to say "home", and `""` also means
-"no preference". A request could then never choose the home over a non-home
-app default, and an app whose region stopped hosting would be stuck (codex 3).
-A named home makes `"region": "<home name>"` an explicit choice at every door.
+If the switch is on and the home is unnamed, the console logs that once at
+startup and behaves as if the switch were off.
+
+Without a named home, `""` is the only way to say "home", and `""` also means
+"no preference". No request could then choose the home over a non-home app
+default. A named home makes `"region": "<home name>"` an explicit choice at
+every door.
While the switch is effectively off:
@@ -209,153 +277,187 @@ While the switch is effectively off:
- **The door's fill still runs.** Turning the switch off stops new promises.
It never breaks the ones already made.
-### 5.7 Mixed versions and rollback (codex 4)
+### 5.7 Mixed versions and rollback (codex 4, R7)
The console owns its SQLite database, so one database is served by one
-console at a time. "Mixed versions" means a rollback, or a roll that has not
-reached a deployment's console yet. A console older than this feature ignores
-the columns. After a rollback it would place a no-region create for a `uk`
-app at the home, succeeding outside the app's promised region.
+console at a time. Mixed versions therefore means a rollback, or a roll that
+has not yet reached a deployment's console. A console older than this feature
+ignores the columns, so after a rollback it would land a no-region create for
+a `uk` app at the home.
-**(decision) The rule:**
+**(decision, under R7) The rule:**
1. **Roll the read side first.** The first release carries the migration, the
- fill, the endpoints and the UI with the switch off. With the switch off,
- every app row is `""`, so this release can be rolled back freely.
-2. **The switch is the feature's rollback lever.** Turning it on happens only
- after that release has run cleanly. Turning it off stops new non-home
- values and keeps the fill.
+ fill, the endpoints and the UI, with the switch off. Every app row is then
+ `""`, so the release can be rolled back freely.
+2. **The switch is the feature's rollback lever.** It is turned on only after
+ that release has run cleanly. Turning it off stops new non-home values and
+ keeps the fill.
3. **Once any app holds a non-home region, the binary rollback floor is the
- first region-aware release.** README's console section names that floor
- version. Rolling the binary below it is out of policy. If it happens
- anyway, the failure is today's behaviour, not data loss: no-region creates
- land at the home. On roll-forward, the console logs each app with a
- non-home region that has records with an empty region created after the
- app. That is one log line per app, so the gap is found rather than assumed.
+ first region-aware release.** This is a runbook rule (R7), with no outside
+ guard. It is written in three places:
+ - the README's "Releasing the platform" section (`README.md:227`);
+ - that release's notes;
+ - the console roll recipe in `carlosframework/carloku-infrastructure`.
+
+ Rolling below the floor is out of policy. If it happens anyway, no-region
+ creates land at the home, as they do today. Nothing is lost.
+4. **On roll-forward, the console logs possible placement discrepancies**
+ (codex R2-6). For each app with a non-home region, it lists records that
+ carry an empty region and were created after the app.
+
+ Records that never inherit the app default are excluded: static,
+ addr-backed, replica, pooled and fleet-placed. Those are the shapes §7
+ leaves unfilled. A deliberate home override also produces an empty
+ region, and the record keeps no provenance, so the list is a set of
+ candidates for a person to review, not a detection of rollback damage.
+ The runbook records the rollback interval, so the review can be narrowed
+ to records created inside it.
CLIs mix freely in both directions (§9.6).
## 6. Resolving a region
-### 6.1 Candidates
+### 6.1 Candidates and eligibility (codex R2-1)
**(decision) One function, `regionCandidates(ctx, policy)`, in
-`instancesplacement.go`, from the same `readEdgesFiles` read `placements`
-uses.** Each candidate is `{code, awsRegion, armed}`:
+`instancesplacement.go`, built from the same edges-file read `placements`
+uses.** Each candidate is `{code, awsRegion, eligible}`.
+
+**Eligibility is positive and per box.** `Hosting` checks reachability and
+the activator, but not whether a box will mint an *ordinary* instance:
+
+- A box with `CARLOS_POOLS` set serves the shared pool only if the list names
+ it (`poolServed`, `internal/provision/reconcile.go:1339`).
+- A box with `CARLOS_INBOUND=none` refuses every routed instance
+ (`reconcile.go:925`).
+
+Neither fact is in the edges file today. **(decision) Each box publishes two
+additive fields** in `edgesFile` (`internal/host/edgesfile.go:155-176`):
+
+- `serves_shared_pool`: `poolServed(cfg.Pools, "")`;
+- `accepts_inbound`: `cfg.Inbound != InboundNone`.
+
+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.
+
+A code is **eligible** when at least one reachable box publishing it has
+`hibernation_configured`, `serves_shared_pool` and `accepts_inbound` all
+**positively true**. Unknown is not yes here. That is the reverse of
+`placements`' generosity, and deliberately so: that generosity is right for
+an explicit request, which a person made and can see refused. It is wrong for
+an automatic one.
+
+Until boxes roll with the fields, no coded region is eligible. The guess then
+picks only the home, so the feature cannot outrun the box roll.
+
+Candidate rules:
- **Coded regions**: boxes whose `Placement` passes `provision.ValidRegion`.
- Fleet codes (`fleet/<name>`) fail on the `/`, so customer fleets are never
- candidates. `armed` is `hostingSet(caps)[code]` (`instancesplacement.go:215`):
- reachable **and** hosting, the door's bar for a socket-backed instance.
-- **The home**: boxes publishing no placement, or the home's name, with a dial
- address. Always armed, as `handleAPIRegions` already says
- (`apiregions.go:42-47`).
-- **AWS region**: the `Region` of the armed boxes behind the code. If they
- disagree, the most common wins, ties to the lexically smallest, logged once.
-- **Policy** is one of three states, read with `provision.GetManifest`
- directly and never through `instanceManifest`, which answers 409 or 502
- for the caller (`apiinstances.go:117`) (codex 6):
- - *absent*: no allow-list, no pool. This is app creation, and any app that
- has not run `instances enable`.
+ Fleet codes (`fleet/<name>`) fail on the `/`.
+- **The home**: boxes that publish no placement, or the home's name, and have
+ a dial address. The home is exempt from the positive check, because it is
+ where every legacy create already lands. `handleAPIRegions` already treats
+ it as always armed (`apiregions.go:42-47`).
+- **AWS region**: the `Region` of the eligible boxes behind the code. If they
+ disagree, the most common wins, ties go to the lexically smallest, and the
+ disagreement is logged once.
+- **Policy**, read with `provision.GetManifest` directly (codex 6). This is
+ never `instanceManifest`, which answers 409 or 502 for the caller
+ (`apiinstances.go:117`).
+ - *absent*: no allow-list and no pool.
- *known*: candidates pass through `instanceRegionAllowed`
- (`apiinstances.go:1797`) on the **normalized** code, the door's exact
- predicate. **A pooled manifest yields no candidates and no guess**
- (codex 1). The armed set knows nothing of pools, and the reconciler
- mints a record only on a box serving both its pool and its region
- (`internal/provision/reconcile.go:335`). Pool-aware candidates would
- need eligibility proven by the target box itself, and are a non-goal.
- - *unreadable*: no guess, and an explicit error at the guess endpoint
- (§9.4). An unrestricted policy is never invented.
-- **Dormant gate**: when no box publishes a placement, the home is the only
- candidate and every guess is `default`. A single-box deployment sees no
- picker and no change.
+ (`apiinstances.go:1797`) on the **normalized** code. **A pooled manifest
+ yields no candidates and no guess** (codex 1).
+ - *unreadable*: no guess, and an explicit error at the guess endpoint.
+- **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).
### 6.2 Points
A point is a latitude and longitude held in memory for one request.
-**Timezone.** *(decision)* A new package, `internal/geo`, embeds from one IANA
-tzdata release: `zone.tab` (one row per country and zone, ISO 6709
-coordinates), a link table generated from tzdata's `Link` lines, and a
-`VERSION` stamp.
+**Timezone.** *(decision)* A new package, `internal/geo`, embeds three files
+from one IANA tzdata release:
-- `zone.tab`, not `zone1970.tab`, because browsers report names that
- `zone1970.tab` has merged away. `Europe/Amsterdam` has its own row in
- `zone.tab` and none in `zone1970.tab`.
-- Links matter because Chrome reports `Asia/Calcutta` where Firefox reports
- `Asia/Kolkata`.
-- Go's `time/tzdata` carries no coordinates, so it cannot be the source.
+- `zone.tab`, one row per country and zone, with ISO 6709 coordinates;
+- a link table generated from tzdata's `Link` lines;
+- a `VERSION` stamp.
-**Kept current** by `scripts/update-tzgeo.sh <version>`, which fetches the
-release from `data.iana.org`, checks its signature and rewrites the three
-files. It is a release-checklist step beside the Go toolchain bump. A stale
-table only misses zone names newer than it, and those fall through to IP.
+`zone.tab` is used rather than `zone1970.tab` because browsers report names
+that `zone1970.tab` has merged away. For example, `Europe/Amsterdam` has its
+own row in `zone.tab` and none in `zone1970.tab`. The links matter because
+Chrome reports `Asia/Calcutta` where Firefox reports `Asia/Kolkata`. Go's
+`time/tzdata` carries no coordinates, so it cannot be the source.
-The lookup:
+The files are **kept current** by `scripts/update-tzgeo.sh <version>`, which
+fetches the release from `data.iana.org`, checks its signature, and rewrites
+the three files. Running it is a release-checklist step. A stale table only
+misses zone names newer than it, and those fall through to IP.
+
+Lookup:
1. A shape check: at most 64 bytes, one to three `/`-separated segments of
`[A-Za-z0-9_+-]`.
2. Unmappable by rule: `""`, `Local`, `UTC`, `UCT`, `GMT`, `Universal`,
- `Zulu`, `Factory` and all of `Etc/`. These name an offset or a machine,
- not a place (R3).
+ `Zulu`, `Factory` and all of `Etc/`.
3. Links, at most 4 hops.
4. `zone.tab`, case-insensitive.
-A miss at any step is unmappable.
-
-**IP.** *(decision)* `CONSOLE_GEOIP_DB` names an operator-supplied `.mmdb`,
-read in `cmd/console/main.go` beside `CONSOLE_HOME_REGION` (`main.go:278`)
-into a new `Config.GeoIP`.
-
-- **The recommended source is DB-IP Lite (CC BY 4.0).** Its required
- attribution appears on the page that uses it (C24) and in the README.
-- MaxMind GeoLite2 also works. Its EULA allows limited sharing, on
- conditions that include attribution and keeping the data updated. The
- operator owns that compliance.
-- Unset means IP fallback off, the default.
-- Unopenable or not an mmdb: logged once, fallback off, and **the console
- still starts**.
-- The file is opened once and memory-mapped. Replacing it is an atomic
- rename plus a console restart.
-- A lookup takes the record's `location` (City databases), else
- `country.iso_code`, else `registered_country.iso_code`, through country
- points. A lookup error is a miss, logged at most once a minute.
-- Loopback, private, link-local, CGNAT and unspecified addresses are never
- looked up.
+A miss at any step makes the timezone unmappable.
+
+**IP.** *(decision)* `CONSOLE_GEOIP_DB` names an operator-supplied `.mmdb`.
+It is read in `cmd/console/main.go` beside `CONSOLE_HOME_REGION`
+(`main.go:278`) into a new `Config.GeoIP`.
+
+- **Recommended source**: DB-IP Lite (CC BY 4.0). Its attribution appears on
+ the page that uses it (C24) and in the README. MaxMind GeoLite2 also works,
+ under its EULA's conditions, which include attribution and keeping the data
+ updated. The operator owns that compliance.
+- **Unset**: the IP fallback is off, which is the default.
+- **Unopenable**: logged once, the fallback is off, and **the console still
+ starts**.
+- **Lifecycle**: the file is opened once and memory-mapped. Replacing it is an
+ atomic rename plus a console restart.
+- **Lookup**: the `location` field, else `country.iso_code`, else
+ `registered_country.iso_code`, through country points. A lookup error is a
+ miss, logged at most once a minute.
+- **Never looked up**: loopback, private, link-local, CGNAT and unspecified
+ addresses.
The reader is `github.com/oschwald/maxminddb-golang` (ISC), pinned to a
-reviewed version. Test fixtures are pinned files from MaxMind's `MaxMind-DB`
-test data (MIT or Apache-2.0). This avoids writing a database parser.
-
-**Country points.** *(decision)* From the same `zone.tab`, not a second
-dataset. A one-row country uses its row. A multi-row country uses a shipped
-override naming its principal zone (`US` → `America/New_York`, `AU` →
-`Australia/Sydney`, `CA` → `America/Toronto`, `RU` → `Europe/Moscow`, `BR` →
-`America/Sao_Paulo`, `CN` → `Asia/Shanghai`, …). A test fails when a tzdata
-refresh gives a country a second row with no override, so nothing ever
-averages a continent silently.
-
-**Whose IP.** The request's address comes from `s.clientIP`
-(`internal/console/authcli.go:876`), which trusts `X-Forwarded-For` only from
-the edge. *(decision)* **Only a person's request is located by its own
-address.** A service or app principal is a server (amadan Pages, titogo), and
-its address says where the server is. Those callers get the IP step only by
-sending an address in the guess body (§9.4).
+reviewed version. The tests use pinned fixtures from MaxMind's `MaxMind-DB`
+test data (MIT or Apache-2.0).
+
+**Country points.** *(decision)* These come from the same `zone.tab`, not a
+second dataset.
+
+- A one-row country uses its row.
+- A multi-row country uses a shipped override naming its principal zone:
+ `US` → `America/New_York`, `AU` → `Australia/Sydney`, `CA` →
+ `America/Toronto`, `RU` → `Europe/Moscow`, `BR` → `America/Sao_Paulo`,
+ `CN` → `Asia/Shanghai`, and so on.
+- A test fails when a tzdata refresh gives a country a second row with no
+ override.
+
+**Whose IP.** The client IP from `s.clientIP` reaches the guess only for a
+person's request (§5.5). A service or app principal is a server, so it gets
+the IP step only by sending an address in the guess body (§9.4).
### 6.3 Nearest
-Each candidate's AWS region maps to a point through the region table (§13).
-Distance is haversine (mean Earth radius 6371.0088 km), rounded to whole km.
+Each eligible candidate's AWS region maps to a point through the region table
+(§14). Distance is haversine (mean Earth radius 6371.0088 km), rounded to
+whole kilometres.
-- A candidate with no point (AWS region unknown, or not in the table) is left
- out and logged once per code.
-- **If the home has no point, the guess is `default`.** Picking among the rest
- could choose a far region over an unmeasured near home, and the home is
- where today's behaviour lives.
-- **Ties** (equal rounded distance) go to the home, then the lexically
- smallest code, so two codes in one AWS region resolve identically on every
- console.
-- An unarmed candidate is never picked.
+- A candidate with no point is left out and logged once per code.
+- **If the home has no point, the guess is `default`.**
+- **Ties** go to the home, then to the lexically smallest code.
+- An ineligible candidate is never picked.
### 6.4 Order
@@ -367,21 +469,23 @@ 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 policy is pooled or
-unreadable, or when the allow-list excludes the home and no nearest exists.
+inputs. There is no guess (`"guess": null`) when:
+
+- the policy is pooled or unreadable, or
+- the allow-list excludes the home and no nearest exists.
### 6.5 Logged, and not logged (codex 8)
-One line per app creation, `console: app region <sqid>/<app>: <code> (<source>)`.
-Once per process: an unusable `CONSOLE_GEOIP_DB`, a code with no point, or
-boxes disagreeing on a code's AWS region.
+- One line per app creation:
+ `console: app region <sqid>/<app>: <code> (<source>)`.
+- Once per process: an unusable `CONSOLE_GEOIP_DB`; a code with no point;
+ boxes disagreeing on a code's AWS region.
**The timezone, the IP and any coordinate never reach a URL, a log line, an
-audit row, a response body or the database.** They travel only in POST bodies
-(§9.4, §10). The canonical-host redirect copies the request's whole query
-string into its `Location` header and HTML (`server.go:1306`, `:1326`), so a
-query parameter would be echoed back. A 308 on a POST keeps the body out of
-both.
+audit row, a response body or the database.** They travel only in POST
+bodies. The canonical-host redirect copies the whole query string into its
+`Location` header and its HTML (`server.go:1306`, `:1326`). A 308 on a POST
+keeps the body out of both.
## 7. The instance create door
@@ -407,42 +511,57 @@ if raw == "" && placed == "" && m.Pool == "" &&
region := s.homeRegionIn(raw)
```
-It sits after `appPlacement` (`apiinstances.go:763`) and before
+**Where it sits.** After `appPlacement` (`apiinstances.go:763`), and before
`checkReplicaCreate` (`:781`), the allow-list (`:799`) and the armed gate
-(`:827`). **Every check an explicit region meets, a defaulted one meets too**
-(R2). An explicit home name is non-empty, so it never triggers the fill, and
+(`:827`).
+
+**Every check an explicit region meets, a defaulted one meets too** (R2). An
+explicit home name is non-empty, so it never triggers the fill, and
`homeRegionIn` then turns it into `""`. That is the override that always
-exists once §5.6 holds (codex 3). Each condition earns its place:
+exists once §5.6 holds (codex 3).
+
+Each condition earns its place:
| condition | why the default stays out |
| --- | --- |
| request names a region | an explicit choice wins (R2) |
| `placed != ""` | a fleet placement already governs every instance's region (`:785`) |
-| `m.Pool != ""` | a defaulted region with no box serving the pool mints nowhere (§6.1) |
-| `req.Role != ""` | a replica must name its placement. Filling it would turn "role replica needs a placement" (`:578-581`) into a silent acceptance. |
-| not `DerivesPaths()` | static and addr-backed records serve everywhere, or proxy to processes the platform never starts. Only a socket-backed instance has a process and data that region places. |
-| store read fails | refused, as the placement read above it is (`:764-768`). Falling back to the home is the bug this spec fixes. |
-
-**Refusals name their origin.** If `regionFrom == "app"` and the allow-list or
-the armed gate fails, the message says the region came from the app and how
-to override it (C16, C17). "region uk is not armed" would puzzle a caller who
-sent no region.
-
-*(decision)* **A defaulted region that fails is refused, not quietly replaced
-by the home.** The app's region is a placement promise (R1), and silent
-relocation is how 116 instances ended up in one place. The way out is always
-an explicit region, the home's name included (§5.6). The refusal is visible
-before anyone tries, in `app_default` (§9.3) and on the Instances tab (§10)
-(codex 7).
+| `m.Pool != ""` | a defaulted region with no box serving the pool mints nowhere |
+| `req.Role != ""` | a replica must name its placement; filling it would turn "role replica needs a placement" (`:578-581`) into a silent acceptance |
+| not `DerivesPaths()` | only a socket-backed instance has a process and data that region places |
+| store read fails | refused, as the placement read above it is (`:764-768`) |
+
+**An app-derived region needs positive eligibility** (codex R2-2). The armed
+gate goes dormant when no file publishes a placement (`:848`). That
+dormancy stays for explicit requests and for the legacy home default. For
+`regionFrom == "app"` it does not apply. The door reads the edges files
+strictly (§6.1) and refuses in three cases:
+
+- **The read failed**: a 503 with C28. The region could not be confirmed,
+ which is different from "absent".
+- **No eligible box publishes the code**: C16, naming `absent`, `not-hosting`
+ or `not-eligible`. This covers the last box in a region being retired, and
+ every box losing its placement code.
+- **The allow-list excludes it**: C17.
+
+The armed gate's own checks then run as before.
+
+*(decision)* **A failing defaulted region is refused, not quietly replaced by
+the home.** The way out is always an explicit region, the home's name
+included, and `app_default` (§9.3) and the Instances tab (§10) show the
+refusal before anyone tries (codex 7).
*(decision)* **A defaulted home whose region is in its own `replicas` set is
-refused** (C26). A home record and its mirror in the same placement is not a
-replica. Explicit requests keep today's behaviour, because tightening #162's
-door for them is a separate change.
+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 `region_from`
-(`request`, `app`, `fleet`, or omitted for the home default) and `region_name`
-(§13). Both sit outside the record, like `PrivateStoreBucket`.
+**Response.** `apiRecordView` (`apiinstances.go:535`) gains two fields that
+sit outside the record, like `PrivateStoreBucket`:
+
+- `region_from`: `request`, `app` or `fleet`, omitted for the home default;
+- `region_name` (§14).
## 8. Every create path
@@ -450,466 +569,646 @@ door for them is a separate change.
| path | file:line | change |
| --- | --- | --- |
-| New-app form, GET | `handleNewAppForm`, `server.go:3586`; `app_new.html` | picker with an `auto` option (§10), when the switch is on and two or more regions are armed |
-| New-app form, POST | `handleNewApp`, `server.go:3607`; claim at `:3626` | `region` is `auto` or a code; `tz` hidden field; builds `regionReq` (§5.5) |
-| CLI API create | `handleAPICreateApp`, `internal/console/apicli.go:330`; body `:359`; claim `:415`; response `:461` | `region`, `timezone` in; `region`, `region_name`, `region_source`, `regions_available` out |
-| The claim | `claimApp`, `server.go:3466`; insert `:3550` | takes `regionReq`; resolves after adoption is known; `CreateAppInRegion` (§5.3) |
-| `carlos apps create` | `runAppsCreate`, `apps.go:442`; call `:497`; print `:502` | `--region`; sends timezone; prints the region line (§11) |
-| `carlos deploy` onboarding | `offerCreateApp`, `deploy.go:513`, reached from `runDeploy` `:113`; call `:536`; print `:540` | `--region`; sends timezone; prints the region line |
-| CLI client | `apiSession.createApp`, `apimode.go:994`; `apiCreateAppResult`, `:958` | two `omitempty` fields in; four tolerant fields out |
-| Legacy owners backfill | `BackfillOpsMemberships`, `store.go:623`, insert `:665` | none: defaults mean the home |
+| New-app form, GET | `handleNewAppForm`, `server.go:3586`; `app_new.html` | picker with an `@auto` option (§10), when the switch is on and two or more regions are eligible |
+| New-app form, POST | `handleNewApp`, `server.go:3607`; claim at `:3626` | `region` is `@auto` or a code; `tz` from a hidden field; `ip` from `s.clientIP`; builds `regionReq` (§5.5) |
+| CLI API create | `handleAPICreateApp`, `internal/console/apicli.go:330`; body at `:359`; claim at `:415`; response at `:461` | takes `region` and `timezone`; `ip` from `s.clientIP` for a person; returns `region`, `region_name`, `region_source` and `regions_available` |
+| The claim | `claimApp`, `server.go:3466`; insert at `:3550` | takes `regionReq`; resolves once it knows whether this is an adoption; `CreateAppInRegion` (§5.3) |
+| `carlos apps create` | `runAppsCreate`, `apps.go:442`; call at `:497`; print at `:502` | `--region`; sends the timezone; prints the region line (§11) |
+| `carlos deploy` onboarding | `offerCreateApp`, `deploy.go:513`, reached from `runDeploy` at `:113`; call at `:536`; print at `:540` | `--region`; sends the timezone; prints the region line |
+| CLI client | `apiSession.createApp`, `apimode.go:994`; `apiCreateAppResult`, `:958` | two `omitempty` fields in, four tolerant fields out |
+| Legacy owners backfill | `BackfillOpsMemberships`, `store.go:623`; insert at `:665` | none: the defaults mean the home |
*(decision)* An explicit region at app create is validated the way the
-instance door validates one: `ValidRegion`, then armed and hosting unless the
-gate is dormant, with the home's name accepted through `homeRegionIn`.
+instance door validates one:
+
+1. `ValidRegion`.
+2. Armed and hosting, unless the gate is dormant.
+3. The home's name is accepted through `homeRegionIn`.
+
Refusals list what would pass (C18, C19). There is no allow-list yet, because
-there is no manifest yet. A fleet `place` and a `region` may both be sent, and
-both are stored. The fleet governs while the app is placed, and the region
-applies if the placement is ever cleared.
+there is no manifest yet. A fleet `place` and a `region` may both be sent,
+and both are stored (§12).
### 8.2 Where an instance is created
| path | file:line | today | change |
| --- | --- | --- | --- |
-| The door | `handleAPIInstanceCreate`, `apiinstances.go:608`, region `:772` | `""` means home | the fill (§7) |
-| `carlos instances create` | `instances_cli.go:322` | `--region` or `""` | help text; prints `region_name` (§11) |
-| `carlos canary` | `canary.go:407` | `""` | the door fills the app's region; new `--region`, with the existing-canary rule (§11) |
-| `carlos deploy` static host | `mintPlatformHost`, `deploy.go:858` | static, `""` | none: static is not filled |
+| The door | `handleAPIInstanceCreate`, `apiinstances.go:608`; region at `:772` | `""` means the home | the fill and eligibility (§7); the latency stamp (§13) |
+| `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 |
| First-route domain attach | `internal/console/domainattach.go:178` | static, no region | none |
| External callers (titogo, jam) | `POST …/instances`, `internal/console/authcli.go:613` | usually `""` | follow the app's region; can ask for a guess (§9.4) |
| Sidecar create | `handleAPISidecarCreate`, `internal/console/apisidecars.go:257` | `""` or explicit | none (§12) |
## 9. API changes
-All of it is additive: new request fields are optional and new response
-fields are ignorable. `readJSON` is not strict (`authcli.go:932-947`), so an
+All changes are additive: new request fields are optional and new response
+fields can be ignored. `readJSON` is not strict (`authcli.go:932-947`), so an
old console ignores the new request fields instead of refusing them.
-### 9.1 `POST /api/cli/apps/{account}/new`
+**9.1 `POST /api/cli/apps/{account}/new`.** The request gains two optional
+fields:
-The request gains two fields:
+- `region`: an explicit choice, as a code or the home's name;
+- `timezone`: a hint, used only when there is no `region`, and never stored.
-- `region`: an explicit choice, either a code or the home's name.
-- `timezone`: a hint, used only without `region`, and never stored.
-
-The response gains these, echoing what was **stored**:
+The response gains these fields, echoing what was **stored**:
```json
{"region": "uk", "region_name": "UK · London", "region_source": "timezone",
"regions_available": 3}
```
-`region` is the stored value under its name (`homeRegionOut`).
-`regions_available` counts armed candidates, and the CLI stays silent below
-two. With the switch off (§5.6), an explicit non-home `region` is answered
-with the home and `default`. The CLI compares the two and says so (C13b).
+- `regions_available` counts eligible candidates.
+- With the switch off, an explicit non-home `region` is answered with the
+ home and `default`, and the CLI says so (C13b).
-### 9.2 `POST /api/cli/apps/{account}/{app}/instances`
+**9.2 `POST /api/cli/apps/{account}/{app}/instances`.** The request is
+unchanged and behaviour follows §7. The response gains `region_from` and
+`region_name`.
-The request is unchanged, and the behaviour follows §7. The response gains
-`region_from` and `region_name`.
+**9.3 `GET /api/cli/apps/{account}/{app}/regions`.** The gate, the role
+(`authcli.go:586`) and the legacy fields are unchanged (codex 6):
-### 9.3 `GET /api/cli/apps/{account}/{app}/regions`
+- `regions` is still the observed topology, unfiltered.
+- `default` is still `HomeRegion`.
+- Each entry gains `aws_region` and `name`.
-The gate and role are unchanged (`authcli.go:586`), and so are the legacy
-fields (codex 6). `regions` and `default` keep today's meaning and values.
-`default` is still `HomeRegion`, and the list is still the observed topology,
-unfiltered. Each entry gains `aws_region` and `name`, and the response gains
-one block:
+The response gains one block:
```json
-"app_default": {"code": "uk", "name": "UK · London", "source": "timezone",
- "available": false, "reason": "absent"}
+"app_default": {
+ "region": "uk", "name": "UK · London", "source": "timezone",
+ "effective": "uk",
+ "available": false, "reason": "absent"
+}
```
-`app_default` is where a create that names no region lands for **this** app,
-and whether it can land there now. That makes it the answer to "why are my
-creates refused" (codex 7).
-
-- `code` is the app's region under its name, or the home for an app with
- no region of its own, or for a pooled or fleet-placed one.
-- `reason` is one of:
- - `absent`: no box publishes the code. The code is reported even though
- `regions` does not list it.
- - `not-hosting`: the region is reachable but cannot host.
- - `not-allowed`: the manifest's allow-list excludes it.
- - `pooled`: the app's manifest names a pool, so the default is the home
- (§7).
- - `fleet`: a fleet placement governs.
- - `policy-unreadable`: the manifest read failed, and `available` is
- omitted.
-- An absent manifest is not an error. It reads as no pool and no allow-list,
- so this endpoint stays as available as it is today.
-- No query parameters are added (codex 8).
-
-### 9.4 `POST /api/cli/apps/{account}/{app}/regions/guess`
-
-This is a new route, under the same gate and role as §9.3. It is how titogo
-asks on behalf of its own visitor (codex 8). The request body is
-`{"tz": "...", "ip": "..."}`, and both fields are optional. A person's request
-with no `ip` uses its own address, and a service or app principal's never
-does (§6.2).
-
-The answer is `{"guess": {"code", "name", "source"}}`, or `{"guess": null}`
-when the policy is pooled or nothing qualifies. It is a 502 with C27 when the
-manifest cannot be read. The guess is per caller and writes nothing. A
-per-instance picker preselects it and sends it back as an explicit region,
-which the door checks like any other.
-
-### 9.5 Browser: `POST /a/{sqid}/apps/new/region`
-
-This route takes a form body with `csrf` and `tz`, is session-gated through
-`s.account`, and returns `{"code", "name", "source"}` for the new-app page's
-script (§10). It writes nothing. `new` is a reserved app name
-(`server.go:1150`), so the route cannot collide with an app's.
-
-### 9.6 Compatibility and mixed versions
+- `region` is the app's **stored** region under its name, always. It is the
+ only field `deploy --region` compares against (codex R2-4).
+- `effective` is where a create that names no region actually lands:
+ - the stored region;
+ - the home, for an app with no region of its own or a pooled app;
+ - the fleet placement itself (`fleet/acme`), for a fleet-placed app, never
+ the platform home (codex R2-4).
+- `available` and `reason` describe `effective`:
+ - `absent`: the code is reported even though `regions` does not list it
+ (codex 7);
+ - `not-hosting`;
+ - `not-eligible` (§6.1);
+ - `not-allowed`;
+ - `pooled`;
+ - `fleet`: `available` is omitted, because the fleet's own rail decides;
+ - `policy-unreadable`: `available` is omitted;
+ - `edges-unreadable`: `available` is omitted.
+- An absent manifest is not an error, and no query parameters are added
+ (codex 8).
+
+**9.4 `POST /api/cli/apps/{account}/{app}/regions/guess`.**
+
+- Same gate and role as §9.3.
+- The body is `{"tz": "...", "ip": "..."}`, both optional. A person's request
+ with no `ip` uses its own address, and a service principal's never does.
+- The answer is `{"guess": {"code", "name", "source"}}` or `{"guess": null}`.
+- An unreadable manifest gets a 502 with C27.
+- It writes nothing.
+
+**9.5 Browser: `POST /a/{sqid}/apps/new/region`.**
+
+- A form body with `csrf` and `tz`.
+- Gated by session through `s.account`.
+- Returns `{"code", "name", "source"}`.
+- Writes nothing.
+- `new` is a reserved app name (`server.go:1150`).
+
+**9.6 Compatibility and mixed versions.**
| client | console | result |
| --- | --- | --- |
-| old CLI | new | no `timezone`: IP for a person, then home. Never an error. Extra fields ignored. |
-| new CLI | old | new fields ignored, and the response has no `region`. The CLI prints nothing, or C13a if `--region` was given. |
-| new CLI | new, switch off | the app is created at the home. With `--region`, C13b. |
-| titogo, unchanged | new | creates follow the app's region, which for every existing app is the home. `default` is unchanged. |
-| any | rolled back below the floor | the §5.7 rule: no-region creates land at the home and are logged on roll-forward |
+| old CLI | new | No `timezone` is sent, so IP for a person, then home. Never an error. |
+| new CLI | old | The response has no `region`. The CLI prints nothing, or C13a if `--region` was given. |
+| new CLI | new, switch off | The app goes to the home. With `--region`, C13b. |
+| titogo, unchanged | new | Creates follow the app's region, which for every existing app is the home. `default` is unchanged. |
+| any | rolled back below the floor | R7: no-region creates land at the home, and roll-forward lists the possible discrepancies (§5.7). |
## 10. Console UI
-### New-app page (`app_new.html`), shown when the switch is on and two or more regions are armed
-
-- A `<select name="region">` sits under the name. Its first option has value
- `auto` and the label C3, naming the server's guess. Server-side, that guess
- is IP then home, since a render cannot see the browser's timezone. Every
- armed region follows as an option with its code as the value and its place
- name as the label (C1, C2, C23). A hidden `tz` field rides along.
-- **`auto` is not a region** (codex 2). Selecting a code is the person's
- choice. Leaving `auto` asks the server to guess, and on an adoption the
- guess is discarded (§5.5). No hidden guess or source field is trusted.
-- An inline script, progressive enhancement in the style of the account
- switcher (`templates/layout.html:583`), fills `tz` from
- `Intl.DateTimeFormat().resolvedOptions().timeZone` and POSTs it to §9.5. It
- only relabels the `auto` option with the answer. The value stays `auto`.
- With JavaScript off, the IP-or-home label stands.
-- With the switch off, fewer than two armed regions, or a failed candidate
- read, there is no picker, and the form is byte-identical to today's.
-- DB-IP attribution (C24) is shown under the picker when the IP step is
+**New-app page** (`app_new.html`), when the switch is on and two or more
+regions are eligible:
+
+- **The picker.** A `<select name="region">` whose first option has the value
+ `@auto` and the label C3, naming the server's guess (IP, then home). Every
+ eligible region follows, with its code as the value and its place name as
+ the label (C1, C2, C23). A hidden `tz` field rides along.
+- **`@auto` is not a region** (codex 2, codex R2-3). Selecting a code is the
+ person's choice, and leaving `@auto` asks the server to guess.
+- **The script.** An inline progressive-enhancement script, in the style of
+ the account switcher (`templates/layout.html:583`), fills `tz`, POSTs it to
+ §9.5, and relabels only the `@auto` option.
+- **Otherwise** there is no picker, and the form is byte-identical to today's.
+- **Attribution.** DB-IP attribution (C24) shows when the IP step is
configured.
-### POST (`handleNewApp`)
+**POST** (`handleNewApp`):
-- A code in the armed set is `explicit`.
-- `auto`, or no `region` field (one region, or a form cached from before the
- roll), means the server resolves from `tz`, then IP, then home. On an
- adoption it resolves to `""`.
-- A code outside the armed set re-renders with C5.
-- The success flash (`server.go:3644`) is existing copy and is untouched.
-- The audit detail (`:3643`) gains the place (C21).
+- An eligible code is `explicit`.
+- `@auto`, or no `region` field at all, makes the server resolve from `tz`,
+ then the request's IP, then the home. On an adoption that resolves to `""`.
+- Any other value re-renders the form with C5.
+- The success flash (`server.go:3644`) is existing copy and is untouched. The
+ audit detail (`:3643`) gains the place (C21).
-### Instances tab (Placement section, `app_instances.html:60-78`), from `app_default` (§9.3)
+**Instances tab** (the Placement section, `app_instances.html:60-78`), from
+`app_default`:
-- C8 shows **whenever the default cannot accept a create**, however many
- regions are armed, and names the reason (codex 7).
+- C8 shows whenever the default cannot accept a create.
- Otherwise C7 shows when the app has a non-home region, or when two or more
- regions are armed.
+ regions are eligible.
- Pooled and fleet-placed apps show neither line.
## 11. CLI
-### Timezone detection
-
-The helper lives in the root package. Its `getenv`, `readlink` and `readFile`
-are injected, and it uses only portable calls, so the `20-cross` gate builds
-it for darwin, windows and linux-arm64 with no build tags. It tries, in order:
+**Timezone detection** is a helper in the root package, with `getenv`,
+`readlink` and `readFile` injected and only portable calls, so `20-cross`
+builds it everywhere. It tries, in order:
-1. `$TZ`, with a leading `:` stripped. A path keeps what follows `zoneinfo/`.
-2. `readlink /etc/localtime`, keeping what follows `zoneinfo/`. This covers
- Linux and macOS's `/var/db/timezone/zoneinfo/…`.
+1. `$TZ`, with a leading `:` stripped; a path keeps what follows `zoneinfo/`.
+2. `readlink /etc/localtime`, keeping what follows `zoneinfo/`.
3. The first line of `/etc/timezone`.
4. Nothing.
-On Windows only a valid `$TZ` is sent. Otherwise the console falls back to IP,
-then the home, and `--region` is always available. This is accepted for this
-release, since Windows zone names would need a CLDR mapping.
-
-### Flags
+On Windows only a valid `$TZ` is sent. Otherwise the console falls back to
+IP, then the home.
| command | flag | meaning |
| --- | --- | --- |
-| `carlos apps create` | `--region <code>` | the app's region. The default is resolved by the console. |
-| `carlos deploy` | `--region <code>` | the region of the app this deploy creates. On an existing app it reads `app_default.code` (§9.3): silent if equal, and **refused before anything ships** if different (C15). Skipped against a console with no `app_default`. |
-| `carlos canary` | `--region <code>` | the canary's region; the default is the app's. If the canary host already exists, the record's region is compared with the flag. A record region of `""` reads as the `regions` response's home. Equal proceeds. **Different is refused before any record write, rechannel or upload** (C25) (codex 5). Moving a canary is a move, not this verb. |
-| `carlos instances create` | `--region <code>` | unchanged; the help says the default is now the app's region |
+| `carlos apps create` | `--region <code>` | The app's region. The default is resolved by the console. |
+| `carlos deploy` | `--region <code>` | The region of the app this deploy creates. On an existing app, it compares against `app_default.region` (the stored region) (codex R2-4): equal is silent, and **different is refused before anything ships** (C15). It is skipped against a console with no `app_default`. A fleet-placed app is judged by its stored region too, because the flag names a region, not a fleet. |
+| `carlos canary` | `--region <code>` | The canary's region. The default is the app's. For an existing canary host, the record's region is compared with the flag, reading `""` as the home's name: equal proceeds, and **different is refused before any record write, rechannel or upload** (C25) (codex 5). |
+| `carlos instances create` | `--region <code>` | Unchanged. The help says the default is now the app's region. |
-### Output and docs
+Output:
-- After "created app …" (`apps.go:502`, `deploy.go:540`), one line by source,
- C9 to C12, only when `regions_available >= 2`.
-- `carlos instances create` and `carlos canary` add the place to their
- existing line when the response carries `region_name` (C14).
+- After "created app …" (`apps.go:502`, `deploy.go:540`), one line per
+ source, C9 to C12, printed only when `regions_available >= 2`.
+- `instances create` and `canary` add the place to their existing line (C14).
- No prompts (R4).
-- `docs/site/cli.md` gains the flags under `### carlos apps`,
- `### carlos deploy` and `### carlos canary`, and the new default under
- `### carlos instances`. There is no new command, so the docsite gate is
- unaffected.
+
+`docs/site/cli.md` gains the new flags and the new default. There is no new
+command.
## 12. Interactions
- **Customer fleets.** `appPlacement` (`internal/console/fleets.go:479`)
- overrides the request's region today (`apiinstances.go:785-797`). The fill
- runs only when `placed == ""`, so the precedence is explicit: **fleet
- placement, then the request's region, then the app's region, then the
- home.** A placed app's region is stored and stays dormant until the
- placement is cleared.
-- **Pools** (`Manifest.Pool`). There is no default fill (§7) and no guess
- (§6.1), and `app_default` reports `pooled`. An explicit `--region` behaves
- as it does today, and the hazard it carries today is named and not fixed
- here: a region with no box serving the pool mints nothing. A test pins that
- the guess cannot lead a pooled app there (codex 1).
-- **Replicas (#162).** A `role: replica` create is never filled, so its
- refusal stands. A defaulted home inside its own `replicas` set is refused
- (§7).
+ overrides the request's region (`apiinstances.go:785-797`). The precedence
+ 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`
+ 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
+ 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.** An app whose instances all moved to
- `au` still starts new ones in its own region.
-- **Static.** Not filled, because static is everywhere-class
- (`reconcile.go:228`).
-- **Private storage (#412).** Unaffected, since stores are keyed on
- `(account, app, environment)`.
-- **Sidecars.** Unchanged. `handleAPISidecarCreate` has no armed gate, so
- filling a region there could strand a sidecar where nothing checks. Following
- the app's region needs its own design.
+ **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.
+- **Private storage (#412).** Unaffected.
+- **Sidecars.** Unchanged. Their door has no armed gate, so following the app
+ region needs its own design (§20).
- **Allow-lists and the home's name.** `instanceRegionAllowed` compares the
normalized `""`, so a manifest listing the home's code refuses creates at
- the home today. This change keeps that normalization and reinterprets no
- existing manifest. The README documents that the named home maps to `""`,
- and rollout audits production manifests for `regions` first (§16).
+ the home today. This change keeps that and reinterprets no manifest. The
+ README documents it, and rollout audits production manifests first (§17).
-## 13. Place names
+## 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.
+
+## 14. Place names
**(decision) One table, `internal/geo/awsregions.go`, maps an AWS region code
to `{latitude, longitude, place}`.**
- It covers the AWS commercial regions on AWS's official list as of
- 2026-10-01 (C23). The table records that source and date in a comment.
+ 2026-10-01 (C23), and records that source and date in a comment.
- Coordinates are the metro area's, each checked by hand when the table is
- built. AWS publishes no facility locations, and tens of kilometres never
- change a choice between regions.
+ built.
- A region missing from the table shows its bare code and is left out of the
guess, with a log line.
-- Adding a row when AWS opens a region is the whole maintenance cost.
-- **Used everywhere**: the picker, the Instances tab, every `name` in §9, and
- the CLI lines. The CLI prints the console's `region_name` and needs no
- table of its own.
+- The table is used everywhere: the picker, the Instances tab, every `name`
+ in §9, and the CLI lines.
**`STATUS_BOX_REGION` stays, with a different job.** It is the public status
-page's commitment: operator free text, deliberately coarse, "never derived
-from cloud metadata" (`agent.go:88-92`, README `:578`). This spec neither
-reads it nor derives it. It may stay coarser than the picker ("EU · Ireland"
-beside "Ireland · Dublin"), but it must agree geographically. The README's
-`STATUS_BOX_REGION` entry says so.
+page's commitment: operator free text, deliberately coarse, and "never derived
+from cloud metadata" (`agent.go:88-92`, README `:578`). It may stay coarser
+than the picker, but it must agree geographically, and the README says so.
-## 14. Failure modes
+## 15. Failure modes
| what fails | what happens |
| --- | --- |
| `CONSOLE_GEOIP_DB` unset | IP step off: timezone, then home |
-| mmdb missing, unreadable or corrupt at startup | logged once; IP step off; console starts |
-| mmdb lookup error | a miss; rate-limited log |
-| timezone UTC, `Etc/*`, unknown or malformed | IP step, then home |
-| edges files unreadable | home only; picker hidden; app gets `""`/`default`; `app_default` of a non-home app reads `absent` |
-| home publishes no AWS region | every guess `default`; fix with the box's `CARLOS_AWS_REGION` |
-| an armed region's AWS region not in the table | out of the guess; still selectable; shown by code |
-| app's region later unarmed or absent | defaulted creates refused (C16); explicit regions, the home's name included, still work; `app_default` and C8 say why |
-| app's region excluded by a later allow-list | refused (C17); `app_default.reason` `not-allowed` |
-| app-region read fails at the door | 502 (C20); no record written |
-| manifest unreadable | guess endpoint 502 (C27); `/regions` still answers with `policy-unreadable` |
-| switch on, home unnamed | switch treated as off; logged once |
-| console binary rolled back below the floor | no-region creates land at the home; logged per app on roll-forward (§5.7) |
-| CLI on a VPN or remote shell | timezone usually still right; the line names the region and `--region` |
-| service principal creates an app | no IP step; `default` unless it sends `timezone` or `region` |
-| owner adopts an existing app | `""`/`default` unless `region` is explicit |
-
-## 15. Test plan
-
-### `internal/geo`
+| mmdb missing, unreadable or corrupt at startup | Logged once; IP step off; the console starts. |
+| mmdb lookup error | A miss; rate-limited log. |
+| timezone UTC, `Etc/*`, unknown or malformed | IP step, then home. |
+| boxes not yet publishing the eligibility fields | No coded region is eligible. The guess is the home, and app-derived defaults are refused as `not-eligible`. |
+| edges listing fails | The guess is the home. App-derived creates get 503 C28. Explicit creates are unchanged. |
+| last box of the app's region retired | App-derived creates are refused (C16 `absent`), and `app_default` and C8 say why. |
+| home publishes no AWS region | Every guess is `default`. Fix it with the box's `CARLOS_AWS_REGION`. |
+| an eligible region's AWS region is not in the table | Left out of the guess, still selectable, shown by code. |
+| app's region excluded by a later allow-list | Refused (C17). |
+| app-region read fails at the door | 502 (C20); no record written. |
+| 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. |
+| owner adopts an existing app | `""`/`default`, unless `region` is explicit. |
+
+## 16. Test plan
+
+**`internal/geo`.**
- Haversine on known pairs: Dublin to London is about 464 km, within 5.
- Nearest:
- - one candidate, and a clear winner;
- - an exact tie goes to the home, and a non-home tie to the smaller code;
+ - a single candidate, and a clear winner;
+ - ties go to the home, then the smaller code;
- two codes in one AWS region;
- - an unarmed candidate nearer than every armed one is never picked;
+ - an ineligible candidate nearer than every eligible one is never picked;
- a candidate with no point is excluded, a home with no point gives
`default`, and no candidates gives `default`.
- Timezones:
- - `Europe/London` maps, and `Asia/Calcutta` and `Asia/Kolkata` give one
- point;
- - `Europe/Amsterdam` maps (it is in `zone.tab`, not `zone1970.tab`);
+ - `Europe/London` and `Europe/Amsterdam` map;
+ - `Asia/Calcutta` and `Asia/Kolkata` give one point;
- `America/Argentina/Buenos_Aires` maps, and matching is case-insensitive;
- - `UTC`, `Etc/UTC`, `Etc/GMT+5`, `GMT`, `Factory`, `Local`, `""`, a 65-byte
- name, `../etc/passwd` and a link loop are all unmappable.
-- Country points: every multi-row country has an override (this is the test a
- tzdata refresh trips), and every override names a real zone.
-- mmdb, using pinned `MaxMind-DB` fixtures:
- - unset, a missing path, a truncated file and a non-mmdb file;
- - a City record gives its location, and a Country-only record its country
- point;
- - private, loopback and CGNAT addresses never reach the reader, as a
- counting fake proves.
-
-### Store
+ - `UTC`, `Etc/UTC`, `Etc/GMT+5`, `GMT`, `Factory`, `Local`, `""`, a
+ 65-byte name, `../etc/passwd` and a link loop are all unmappable.
+- Country points: every multi-row country has an override.
+- mmdb, against pinned fixtures:
+ - unset, missing, truncated and non-mmdb files;
+ - City and Country-only records;
+ - private, loopback and CGNAT addresses never reach the reader.
+
+**Edges file and eligibility** (codex R2-1):
+
+- The writer publishes `serves_shared_pool` and `accepts_inbound` from
+ `CARLOS_POOLS` and `CARLOS_INBOUND`, for:
+ - unset;
+ - `CARLOS_POOLS=tito`;
+ - `CARLOS_POOLS=,tito`, which includes the shared pool;
+ - `CARLOS_INBOUND=none`.
+- 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.
+- The guess against a no-inbound box does the same.
+- In both cases, the matching reconcile pass on that box refuses the record
+ (`reconcile.go:925`, `:1339`). That pins why the guess must not go there.
+
+**Store.**
- Four database states: fresh; from before the columns; from before
- `migrateAppsKey` but carrying the new ALTERs (the columns must survive the
- rebuild); and `migrate` run twice.
+ `migrateAppsKey` but carrying the new ALTERs; and `migrate` run twice.
- Pre-existing rows read `""`/`""`.
-- `CreateAppInRegion` writes both columns in one statement, and `CreateAppIn`
- and `CreateApp` write `""`/`""`.
+- `CreateAppInRegion` writes both columns in one statement.
- Rename and transfer keep the region.
-### The door (`apiinstances_test.go`)
+**The door** (`apiinstances_test.go`):
-- No region with app region `uk`, armed: the record says `uk`, and
+- No region, with app region `uk` eligible: the record says `uk` and
`region_from` says `app`.
- App region `""`: the record is byte-identical to the pre-change golden.
-- An explicit region wins over the app's, and a fleet wins over both.
-- An explicit home name with app region `uk` gives `""` (codex 3).
-- Pooled, static, addr-backed and replica creates are not filled. A replica
- with no region gets the existing refusal word for word.
-- An unarmed app region gives C16, an allow-list exclusion C17, a store error
- 502, and a defaulted home inside its own `replicas` C26.
-- A dormant gate accepts app region `uk`, as it accepts an explicit `uk`
- today.
-- **Switch off with an existing `uk` row: the fill still applies** (codex 4).
-
-### Pools, end to end (codex 1)
-
-- A pooled manifest gives `guess: null` and `app_default.reason: pooled`.
-- A record for the pool in a region whose boxes do not serve that pool is out
- of `inScope` on those boxes. This pins the hazard the guess now cannot
- reach.
-
-### App create
-
-- An old body from a person resolves by IP, then home.
+- Explicit wins over the app, and fleet wins over both.
+- An explicit home name gives `""`.
+- Pooled, static, addr-backed and replica creates are not filled, and the
+ replica refusal is word for word.
+- An unavailable app region gives C16, the allow-list C17, a store error 502,
+ and a defaulted home inside its `replicas` C26.
+- **Dormancy versus app-derived defaults (codex R2-2):**
+ - With no placement published, an explicit `uk` is accepted, as today.
+ - An app-derived `uk` is refused with C16 `absent`.
+ - The last UK box's file removed: an app-derived `uk` is refused.
+ - The edges listing fails: an app-derived create gets 503 C28, and an
+ explicit create is unchanged.
+- The switch off with an existing `uk` row: the fill still applies.
+
+**Latency stamp** (R6):
+
+- 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.
+
+**Pools, end to end.** A pooled manifest gives `guess: null`,
+`app_default.reason: pooled`, and no stamp.
+
+**App create.**
+
+- An old body from a person resolves by IP, then home. This needs `ip` passed
+ through `regionReq` (codex R2-5).
- A `timezone` body.
- Explicit regions: valid, invalid (C18) and unarmed (C19).
- `place` plus `region`.
-- Response fields echo what was stored.
-- The switch off, or on with an unnamed home: the app is stored at home.
+- The switch off, or on with an unnamed home.
- A service principal gets no IP step.
-- **Adoption (codex 2):**
- - with `auto`, or with a `timezone` and no `region`, it stores `""`;
- - with an explicit code, it stores that code;
- - a browser POST that never touched the select stores `""` on adoption.
+- Adoption:
+ - with `@auto`, or a `timezone` but no `region`, it stores `""`;
+ - with an explicit code, it stores the code;
+ - **a region named `auto`, and a home named `auto`, both work and are
+ distinguishable from `@auto`** (codex R2-3).
-### Browser
+**Browser.**
-- The switch off, or a single region, renders today's form byte for byte.
-- The `auto` option is labelled with the IP guess.
-- §9.5 needs a session and CSRF, and is 404 to a non-member.
-- A code records `chosen`, and an unarmed code re-renders with C5.
+- One region, or the switch off: the form is byte for byte today's.
+- `@auto` is labelled with the IP guess.
+- §9.5 needs a session and CSRF.
+- An explicit code records `chosen`.
-### Endpoints
+**Endpoints.**
- `/regions` for an existing app matches today's golden in `regions` and
`default`.
-- `app_default` covers each `reason`, including `absent` for a code no box
- publishes.
-- An absent manifest still answers. An unreadable manifest gives
- `policy-unreadable`, and a guess 502.
-
-### Privacy (codex 8)
-
-With a canonical-host redirect configured:
-
-- a guess POST with `tz` and `ip` leaves neither in the `Location` header,
- the redirect body, any error body, the log output captured during the
- test, or any audit row;
-- §9.3 accepts no hint parameters.
-
-### CLI
-
-- Detection, with injected functions: `TZ=":Europe/London"`, a `TZ` path, a
- plain `TZ`, the `/etc/localtime` link on Linux and macOS, `/etc/timezone`,
- and nothing.
-- One output line per source, and silence below two regions.
-- C13a against an old console, and C13b against a console with the switch
- off.
-- `deploy --region` on an existing app: different is refused before anything
- ships (prove nothing was uploaded), and equal is silent.
+- `app_default` covers each `reason`.
+- For an app storing `uk` and placed on `fleet/acme`: `region` is `uk`,
+ `effective` is `fleet/acme`, and `deploy --region uk` passes while
+ `deploy --region ie` is refused (codex R2-4).
+
+**Rollback list** (codex R2-6). A non-home app is not listed for any of:
+
+- its static apex;
+- an addr-backed record;
+- a replica;
+- a pooled record.
+
+A no-region instance record created after the app is listed as a possible
+discrepancy, worded as such.
+
+**Privacy.** With a canonical-host redirect configured, a guess POST's `tz`
+and `ip` appear in none of:
+
+- the `Location` header;
+- the redirect body;
+- any error body;
+- the captured logs;
+- any audit row.
+
+**CLI.**
+
+- Timezone detection with injected functions.
+- One output line per source.
+- C13a and C13b.
+- `deploy --region`: different is refused before anything ships, and equal
+ is silent.
- `canary --region` against an existing canary in another region is refused
- before any record write or upload; in the same region it proceeds
- (codex 5).
-- Shell-out tests use `carlosTestEnv` and a scratch `cmd.Dir` (AGENTS.md), and
- `20-cross` builds the detector on every target.
-
-## 16. Rollout
-
-1. **Before any roll:**
- - audit production manifests for `regions` (§12);
- - confirm each box publishes the AWS region it is in, especially that the
- flagship's Ireland box publishes `region: eu-west-1` with no
- `placement`; use the box's existing `CARLOS_AWS_REGION` override where
- it does not;
- - set `CONSOLE_HOME_REGION` on every console that will turn the switch on.
-2. **Console release, switch off.** It carries the migration, `internal/geo`,
- the fill, the endpoints and the UI. Behaviour is unchanged, and this
- release can be rolled back freely (§5.7). No box roll and no edges-file
- change.
-3. **Verify on dev.** Turn the switch on, with two armed regions. Create an
- app in the browser and read its row. Create an instance with no region and
- read its record. Diff `/regions` for an old app against the answer from
- before the roll.
-4. **Flagship.** Roll the console, then turn the switch on. From here the
- binary rollback floor applies, and the README names it. Optionally place a
- DB-IP Lite file and set `CONSOLE_GEOIP_DB`.
-5. **CLI release.** It is safe in either order (§9.6), but the lines appear
- only against a console with the switch on.
-6. **titogo**, in its own repo, adopts `/regions/guess` and the names (§17 Q1).
-
-Copy review (§18) happens before step 2 ships any string.
-
-## 17. Questions for Paul
-
-1. **Is titogo's adoption part of "done"?** Existing apps keep the home, by
- ruling. If the 116 Ireland instances belong to a few long-lived apps that
- create instances through the API (titogo, Managed Jelly), the platform
- change alone relieves Ireland only for new apps.
- - Option (a): done when the platform ships.
- - Option (b): done when titogo sends `guess` per instance.
- - **Recommendation: (b)**, since relieving Ireland is the point.
-2. **Is latency verified or assumed?** Non-static instance hosts are not
- steered, and the per-instance DNS seam is recorded, not built
- (`apiinstances.go:1052-1057`). If the apps-domain wildcard resolves to the
- home's edge, a UK visitor to a UK-placed instance goes through Ireland and
- back.
- - Option (a): ship as a placement change, and measure the request path
- before claiming any latency gain.
- - Option (b): also default non-home instances to `Steering: latency`.
- - **Recommendation: (a).** Steering is a separate change with its own
- costs, and whether `CARLOS_STEERING_ZONE_ID` is set on the flagship
- needs checking first.
-3. **The rollback floor (§5.7).** Once the switch is on, rolling the console
- binary below the first region-aware release sends no-region creates for
- non-home apps to the home until roll-forward. That is today's behaviour,
- logged afterwards.
- - Option (a): accept the floor as a runbook rule.
- - Option (b): require a mechanism that refuses such creates. An old binary
- cannot carry one, so it would have to live outside the console, for
- example a box-side check.
- - **Recommendation: (a).**
-
-## 18. Copy (pending copy review)
-
-These are drafts. None ships until Paul has reviewed it through copy review.
-There are no em dashes, and `{…}` is filled at runtime.
+ before any write or upload.
+- Tests use `carlosTestEnv` with a scratch `cmd.Dir`.
+- `20-cross` covers the CLI changes.
+
+## 17. Rollout
+
+**Phase P: placement.**
+
+1. **Before any roll.**
+ - Audit production manifests for `regions` (§12).
+ - Confirm each box publishes its AWS region. In particular, confirm the
+ flagship's Ireland box publishes `region: eu-west-1` and no `placement`.
+ Use `CARLOS_AWS_REGION` where it does not.
+ - Set `CONSOLE_HOME_REGION` on every console that will turn the switch on.
+2. **Box release.** It carries the two edges-file eligibility fields. Roll it
+ 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
+ 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;
+ - 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
+ recipe in carloku-infrastructure name the floor version. Optionally, set
+ `CONSOLE_GEOIP_DB` to a DB-IP Lite file.
+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.
+
+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.
+
+## 19. Copy (pending copy review)
+
+These are drafts, and none ships until Paul has reviewed it through copy
+review. There are no em dashes. `{…}` is filled at runtime.
| id | where | draft |
| --- | --- | --- |
| C1 | new-app page, picker label | Region |
| C2 | new-app page, help | Where this app's instances run. We picked the region nearest to you. Choose another if most of your users are somewhere else. |
-| C3 | new-app page, `auto` option | {place} (nearest to you) |
+| C3 | new-app page, `@auto` option | {place} (nearest to you) |
| C5 | new-app page, flash | That region can't take new apps right now. Choose another. |
| C7 | Instances tab | New instances start in {place}, this app's region. |
| C8 | Instances tab, default unavailable | New instances can't start in {place}, this app's region, right now. Pass a region to place one elsewhere. |
@@ -921,17 +1220,24 @@ There are no em dashes, and `{…}` is filled at runtime.
| C13b | CLI, console did not apply --region | This console is not assigning app regions yet, so --region was not applied. This app's instances start in {place}. |
| C14 | CLI, instances create and canary | created instance {host} (channel {channel}, {place}) |
| C15 | CLI, deploy --region on an existing app | {app} already runs in {place}. --region only applies when deploy creates an app. To place one instance elsewhere, use carlos instances create --region. |
-| C16 | API, defaulted region not armed | this app's region {code} can't take new instances right now. Armed regions: {list}. Pass a region to place this instance elsewhere. |
+| C16 | API, defaulted region unavailable | this app's region {code} can't take new instances right now ({reason}). Pass a region to place this instance elsewhere. Regions that can: {list}. |
| C17 | API, defaulted region not allowed | this app's region {code} is not one its instances may use. Allowed: {list}. Pass a region to choose one. |
| C18 | API, app create, unknown region | region {code} is not one this platform runs in. Choose from: {list}. |
| C19 | API, app create, region not armed | region {code} can't take new apps right now. Choose from: {list}. |
| C20 | API, app region unreadable | could not read this app's region. Try again. |
| C21 | activity feed | created app {name} in {place} |
| C22 | flag help | apps create: "region this app's instances start in (default: the region nearest you)". deploy: "region for the app this deploy creates (default: the region nearest you)". canary: "region for the canary instance (default: the app's region)". instances create: "region for this instance (default: the app's region, subject to the manifest's allow-list)". |
-| C24 | new-app page, under the picker, IP step configured | IP location by DB-IP. (Linked to db-ip.com; DB-IP's required wording to be confirmed against its licence page.) |
+| C24 | new-app page, IP step configured | IP location by DB-IP. (Linked to db-ip.com; DB-IP's required wording to be confirmed against its licence page.) |
| C25 | CLI, canary --region on an existing canary | canary {host} already runs in {place}. --region applies only when canary creates the host. Run carlos canary remove first to start it somewhere else. |
| C26 | API, defaulted home inside its replica set | this app's region {code} is also one of this instance's replica placements. Pass a region for the instance, or drop {code} from replicas. |
| C27 | API, guess with unreadable policy | could not read this app's instance policy, so there is no region suggestion. Try again. |
+| C28 | API, app region unconfirmed | could not confirm that this app's region {code} can take new instances. Try again, or pass a region. |
+| C29 | roll-forward log, read by operators | possible placement discrepancy: {host} of {app} has no region, but {app}'s region is {code}. It may be a deliberate home override; review it. |
+
+The `{reason}` words in C16 are `absent`, `not-hosting` and `not-eligible`.
+Shown to a person, they read: "no box runs there now", "boxes there can't
+start instances", and "boxes there don't take ordinary instances". Those are
+copy too.
C23, place names:
@@ -955,39 +1261,72 @@ C23, place names:
| ap-northeast-1 | Japan · Tokyo | ap-southeast-7 | Thailand · Bangkok |
| ap-northeast-2 | South Korea · Seoul | ap-northeast-3 | Japan · Osaka |
-The `docs/site/cli.md` and README additions (§5.7, §11, §12, §13) are prose
-and go through the same review.
+The `docs/site/cli.md`, README and release-note additions (§5.7, §11, §12,
+§14) are prose, and go through the same review.
+
+## 20. Follow-ups (not in this spec)
-## 19. Review dispositions (Astra, round 1)
+- **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`.
+- **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.**
+- **Tightening #162** for explicit home-in-own-replica-set creates.
+
+## 21. Review dispositions
+
+### Round 1 (rev 1, 7fbc9479)
| # | sev | finding | disposition |
| --- | --- | --- | --- |
-| 1 | P1 | A picker submitting the guess bypasses the pool exclusion | **accepted.** Pooled policy yields no candidates and `guess: null` (§6.1); `app_default` reports `pooled`; end-to-end test (§15). |
-| 2 | P1 | Browser adoption silently changes an existing app's default | **accepted.** `regionReq` separates the explicit choice from the hint; the guess resolves after adoption is known and is discarded on adoption; the form's `auto` value replaces the forgeable source label (§5.5, §10). |
-| 3 | P1 | An unnamed home makes override and recovery impossible | **accepted.** Non-home app regions are written only when the home is named; an explicit home name is always an override (§5.6, §7). |
-| 4 | P1 | No mixed-version or rollback rule | **accepted.** Read side ships first with the switch off; the switch is the rollback lever; a binary rollback floor with a roll-forward log (§5.6, §5.7); acceptance of the floor is §17 Q3. |
-| 5 | P2 | `canary --region` undefined for an existing canary | **accepted.** Equal proceeds; different is refused before any write or upload (§11, C25). |
-| 6 | P2 | Manifest absence and read failure unspecified for `/regions` | **accepted.** Read directly, three states; legacy fields unfiltered; unreadable policy gives no guess and an explicit error (§6.1, §9.3, §9.4). |
-| 7 | P2 | The unavailable-default explanation disappears when the region is absent | **accepted.** `app_default` reports availability and reason for codes no box publishes; C8 shows regardless of region count (§9.3, §10). |
-| 8 | P2 | Query-string hints can be reflected by the canonical-host redirect | **accepted.** Hints move to POST bodies (§9.4, §9.5); `/regions` takes no hint parameters; privacy assertions over redirects, errors, logs and audit (§15). |
-
-Astra's recommendations on rev 1's open questions:
-
-| rev 1 Q | recommendation | disposition |
-| --- | --- | --- |
-| Q1 Ireland relief | titogo adoption in the completion criteria | kept for Paul (§17 Q1), recommended |
-| Q2 DNS and latency | verify and measure; no silent steering | kept for Paul (§17 Q2), recommended; steering listed as a non-goal |
-| Q3 unarmed default | keep refusal, usable override, visible status | adopted (§7, §9.3, §10) |
-| Q4 mutable region | creation-only now; a later verb affects future creates only | adopted (§2) |
-| Q5 browser instance form | defer | adopted (§2) |
-| Q6 GeoIP database | DB-IP Lite with attribution; correct the GeoLite2 statement | adopted (§6.2, C24) |
-| Q7 dependency and fixtures | pinned `maxminddb-golang`; pinned MaxMind-DB fixtures | adopted (§6.2) |
-| Q8 place names | keep picker style; require geographic consistency | adopted (§13) |
-| Q9 home coordinates | verify before rollout; use `CARLOS_AWS_REGION`, no new console override | adopted (§16 step 1) |
-| Q10 sidecars | leave unchanged; separate design | adopted (§2, §12) |
-| Q11 pools | suppress pooled guesses now; defer pool-aware candidates | adopted (§6.1) |
-| Q12 home/replica overlap | refuse new home records whose region is in their replica set | adopted for defaulted regions (§7, C26); explicit requests unchanged, because tightening #162's door is a separate change |
-| Q13 allow-list spelling | preserve normalization; audit manifests; document | adopted (§12, §16) |
-| Q14 unnamed home | a warning is not enough | adopted, as finding 3 |
-| Q15 Windows | accept the fallback; honour `$TZ` | adopted (§11) |
-| Q16 AWS list | codes confirmed; record the source; verify coordinates | adopted (§13) |
+| 1 | P1 | A picker submitting the guess bypasses the pool exclusion | **accepted.** No guess for a pooled policy (§6.1). |
+| 2 | P1 | Browser adoption silently changes an existing app's default | **accepted.** `regionReq` with guess resolution after adoption (§5.5). |
+| 3 | P1 | An unnamed home makes override and recovery impossible | **accepted.** Non-home writes need a named home (§5.6). |
+| 4 | P1 | No mixed-version or rollback rule | **accepted.** Read side first, the switch as lever, the binary floor (§5.7). Round 2 marked it "partly" pending acceptance of the floor; R7 settles that. |
+| 5 | P2 | `canary --region` undefined for an existing canary | **accepted** (§11). |
+| 6 | P2 | Manifest absence and read failure unspecified | **accepted.** Three policy states (§6.1, §9.3). |
+| 7 | P2 | Unavailable-default explanation disappears | **accepted.** `app_default` (§9.3), C8 (§10). |
+| 8 | P2 | Query hints reflected by redirects | **accepted.** POST bodies only (§6.5, §9.4, §9.5). |
+
+### Round 2 (rev 2, bd4ed0c4)
+
+| # | sev | finding | disposition |
+| --- | --- | --- | --- |
+| R2-1 | P1 | Automatic candidates can include pool-restricted or no-inbound boxes that never mint shared instances | **accepted.** Two additive edges-file fields, positive eligibility for automatic choices, and a box release before the switch (§6.1, §17). I chose publication over a console-side operator declaration because the box is the only party that knows its own pools and inbound setting, and the `hibernation_configured` precedent is already there. |
+| R2-2 | P1 | Losing the last placement publication disables the unavailable-default refusal | **accepted.** App-derived defaults need positive eligibility and never ride dormancy. A read failure is distinct (503 C28). The contradicting test is replaced (§7, §16). |
+| R2-3 | P2 | `auto` collides with a valid placement code | **accepted.** The sentinel is `@auto`, outside the grammar, with tests for a region and a home named `auto` (§5.5, §10). |
+| R2-4 | P2 | Fleet `app_default.code` misleads the deploy preflight | **accepted.** `app_default.region` (stored) is separate from `effective` (the fleet itself), and deploy compares against `region` (§9.3, §11). |
+| R2-5 | P2 | `regionReq` cannot carry the IP `claimApp` needs | **accepted.** A request-local `ip` from `s.clientIP` in `regionReq`, resolved after adoption, never logged or stored (§5.5). |
+| R2-6 | P2 | The rollback detector flags correct deployments | **accepted.** Non-inheriting shapes are excluded, matches are worded as possible discrepancies for review, and the runbook records the interval (§5.7, C29). |
+
+### Astra's recommendations on rev 1's open questions
+
+| 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). |
+| 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) |
+| Q6 DB-IP Lite, corrected GeoLite2 note | adopted (§6.2, C24) |
+| Q7 pinned reader and fixtures | adopted (§6.2) |
+| Q8 picker style, geographic consistency | adopted (§14) |
+| Q9 verify home publication, `CARLOS_AWS_REGION` | adopted (§17) |
+| Q10 sidecars unchanged | adopted (§20) |
+| Q11 suppress pooled guesses | adopted (§6.1) |
+| Q12 home inside own replica set | adopted for defaulted regions (§7); explicit is a follow-up (§20) |
+| Q13 keep allow-list normalization, audit | adopted (§12, §17) |
+| Q14 unnamed home | adopted, as finding 3 |
+| Q15 Windows fallback | adopted (§11) |
+| Q16 AWS list | adopted (§14) |
+
+### Astra's round-2 recommendations on rev 2's questions
+
+| 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). |
+| Q3 (a): accept the floor | matches R7 |