carlos / platform Public

docs: nearest-region spec rev 2, after Astra's round-1 review

All eight findings verified against the code and accepted, marked
"(codex N)" in the text: pooled apps get no guess; adoption resolves
the guess after it knows it is an adoption and discards it; non-home app
regions need a named home and a CONSOLE_APP_REGIONS switch; a read-side-
first rollout with a binary rollback floor; canary --region on an
existing canary; three-state manifest policy for /regions; app_default
availability for absent regions; location hints moved to POST bodies so
the canonical-host redirect cannot echo them. Twelve of the sixteen open
questions are resolved in the body; three choices remain for Paul.
§19 records every disposition.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev bd4ed0c4482ab7ac28b997378abcf49bea8eddc0 parent be9dd2c
1 file changed, +628 −345
  • docs/superpowers/specs/2026-10-01-nearest-region-default-design.md +628 −345
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 df4f30cc4..d213f17fb 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,13 +1,17 @@
# 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 lists what the code could not settle.
+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). None of them changes here.
+placements), #175/#331 (instance moves).
## 1. The problem, as found
@@ -41,12 +45,13 @@ What exists to build on: placement codes are bare (`ie`, `uk`, `au`),
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`), 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`. There is no geolocation of any kind.
+`: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.
+There is no geolocation of any kind.
## 2. Goals and non-goals
@@ -55,14 +60,25 @@ 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.
- The guess is generic: any deployment, any set of AWS regions.
-- The guess can never name a region the create door would refuse.
+- The guess can never name a region the create door would refuse, or one the
+ reconciler would never mint (codex 1).
- Existing apps, older CLIs and external callers that send nothing behave
exactly as today.
-Non-goals: moving existing instances (#175/#331); box sizing and disk alerts;
-an account-level default (R1); changing an app's region after creation
-(§17 Q4); a browser instance-create form, which does not exist today (§10);
-any box-side change.
+Non-goals:
+
+- Moving existing instances (#175/#331).
+- 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)
@@ -74,7 +90,7 @@ any box-side change.
| 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 from `GET …/regions`; CLI as R4. | §9, §10, §11 |
+| 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 |
## 4. The shape, in one paragraph
@@ -83,11 +99,13 @@ 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, where "resolve" means the great-circle
-nearest region that can host an instance right now. From then on an instance
+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.
+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).
## 5. Data model
@@ -127,12 +145,12 @@ the rule the comment at `:499-503` already records for `placement`,
`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 `region, source` parameters.
+(`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 (§17 Q4), so a failed
-second write would leave the app at the home for good, silently.
+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.
Rename (`store.go:1791`) and transfer (`:1709`) update the same row, so the
region survives both. The legacy owners backfill (`:665`) inserts defaults,
@@ -145,25 +163,85 @@ which mean the home.
`""` 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.
-
-### 5.5 Adoption (#79)
-
-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. **(decision) Adoption stores `""`/`""` unless the request names a
-region.** Restoring a console row must not send a live app's next instance
-somewhere new.
+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.
+
+### 5.5 Explicit choice versus guess, and adoption (codex 2)
+
+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.
+
+**(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 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).
+
+### 5.6 The switch and the named home (codex 3, codex 4)
+
+**(decision) Non-home app regions are written only when both hold:**
+
+- `CONSOLE_APP_REGIONS=on` (new, default off), and
+- `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.
+
+While the switch is effectively off:
+
+- Every new app gets `""`/`default`. An explicit non-home `region` on app
+ create is not stored, and the response shows what was (§9.1).
+- The new-app picker is hidden.
+- The guess endpoint (§9.4) still answers, because a per-instance guess for
+ titogo writes nothing to the app row.
+- **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)
+
+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.
+
+**(decision) 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.
+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.
+
+CLIs mix freely in both directions (§9.6).
## 6. Resolving a region
### 6.1 Candidates
-**(decision) One function, `regionCandidates(ctx)`, in `instancesplacement.go`,
-from the same `readEdgesFiles` read `placements` uses.** Each candidate is
-`{code, awsRegion, armed}`:
+**(decision) One function, `regionCandidates(ctx, policy)`, in
+`instancesplacement.go`, from the same `readEdgesFiles` read `placements`
+uses.** Each candidate is `{code, awsRegion, armed}`:
- **Coded regions**: boxes whose `Placement` passes `provision.ValidRegion`.
Fleet codes (`fleet/<name>`) fail on the `/`, so customer fleets are never
@@ -174,10 +252,20 @@ from the same `readEdgesFiles` read `placements` uses.** Each candidate is
(`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.
-- **Allow-list**: when the app's manifest is known (§9.3), candidates pass
- through `instanceRegionAllowed` (`apiinstances.go:1797`) on the
- **normalized** code, the door's exact predicate. At app creation there is no
- manifest yet, so nothing is filtered.
+- **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`.
+ - *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.
@@ -189,32 +277,55 @@ 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. `zone.tab`, not `zone1970.tab`, because browsers report names
-`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. **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.
-
-Lookup: a shape check (at most 64 bytes, one to three `/`-separated segments
-of `[A-Za-z0-9_+-]`); then unmappable by rule `""`, `Local`, `UTC`, `UCT`,
-`GMT`, `Universal`, `Zulu`, `Factory` and all of `Etc/`, which name an offset
-or a machine, not a place (R3); then links, at most 4 hops; then `zone.tab`,
-case-insensitive. A miss at any step is unmappable.
-
-**IP.** *(decision)* `CONSOLE_GEOIP_DB` names an operator-supplied `.mmdb`
-(GeoLite2/GeoIP2 City or Country, DB-IP City or Country Lite), read in
-`cmd/console/main.go` beside `CONSOLE_HOME_REGION` (`main.go:278`) into a new
-`Config.GeoIP`. Unset means IP fallback off, the default. Unopenable or not an
-mmdb: logged once, fallback off, **the console still starts**. Opened once,
-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.
+`VERSION` stamp.
+
+- `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.
+
+**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.
+
+The 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).
+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.
+
+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
@@ -228,8 +339,8 @@ averages a continent silently.
(`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 from
-an explicit `ip` parameter (§9.3).
+its address says where the server is. Those callers get the IP step only by
+sending an address in the guess body (§9.4).
### 6.3 Nearest
@@ -255,18 +366,22 @@ else IP gives a point → nearest → "ip"
else home → "default"
```
-If the home has no point, the result is the home with `default` whatever the
-inputs. If the allow-list excludes the home (regions endpoint only) and no
-nearest exists, there is no guess (`"guess": null`) and pickers preselect
-nothing.
+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.
+
+### 6.5 Logged, and not logged (codex 8)
-### 6.5 Logged, and not logged
+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>)`,
-and once per process an unusable `CONSOLE_GEOIP_DB`, a code with no point, or
-boxes disagreeing on a code's AWS region. **The timezone, the IP and any
-coordinate never reach a log line, an audit row, a response body or the
-database.**
+**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.
## 7. The instance create door
@@ -295,27 +410,39 @@ region := s.homeRegionIn(raw)
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). Each condition earns its place:
+(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:
| 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 != ""` | the armed set knows nothing of pools; a defaulted region with no box serving the pool mints nowhere (§12) |
-| `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.** When `regionFrom == "app"` and the allow-list
-or 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 fallback is how 116 instances ended up in one place (§17 Q3).
+| `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).
+
+*(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.
**Response.** `apiRecordView` (`apiinstances.go:535`) gains `region_from`
-(`request`, `app`, `fleet`, or omitted for the home default) and
-`region_name` (§13), outside the record like `PrivateStoreBucket`.
+(`request`, `app`, `fleet`, or omitted for the home default) and `region_name`
+(§13). Both sit outside the record, like `PrivateStoreBucket`.
## 8. Every create path
@@ -323,21 +450,22 @@ and silent fallback is how 116 instances ended up in one place (§17 Q3).
| path | file:line | change |
| --- | --- | --- |
-| New-app form, GET | `handleNewAppForm`, `server.go:3586`; `app_new.html` | candidates plus an IP guess for a person; picker when two or more are armed (§10) |
-| New-app form, POST | `handleNewApp`, `server.go:3607`; claim at `:3626` | reads `region`, `tz`, `guess`, `guess_source`; resolves; passes to `claimApp` |
+| 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` | `region, source` parameters; `CreateAppInRegion` (§5.3); `""` on adoption (§5.5) |
+| 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 |
-*(decision)* An explicit region at app create is validated like the instance
-door validates one: `ValidRegion`, then armed and hosting unless the gate is
-dormant, with the home's name accepted through `homeRegionIn`. Refusals list
-what would pass (C18, C19). There is no allow-list yet (no manifest yet). A
-fleet `place` and a `region` may both be sent and both are stored; the fleet
-governs while placed, the region if the placement is ever cleared.
+*(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`.
+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.
### 8.2 Where an instance is created
@@ -345,21 +473,26 @@ governs while placed, the region if the placement is ever cleared.
| --- | --- | --- | --- |
| 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` |
+| `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 |
| 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 read the guess (§9.3) |
-| Sidecar create | `handleAPISidecarCreate`, `internal/console/apisidecars.go:257` | `""` or explicit | **none in this spec** (§17 Q10) |
+| 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 additive: new request fields optional, new response fields ignorable.
-`readJSON` is not strict (`authcli.go:932-947`), so an old console ignores new
-request fields instead of refusing them.
+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
+old console ignores the new request fields instead of refusing them.
+
+### 9.1 `POST /api/cli/apps/{account}/new`
+
+The request gains two fields:
-**9.1 `POST /api/cli/apps/{account}/new`.** Request gains `region` (a code or
-the home's name) and `timezone` (a hint, used only without `region`, never
-stored). Response gains:
+- `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**:
```json
{"region": "uk", "region_name": "UK · London", "region_source": "timezone",
@@ -367,104 +500,157 @@ stored). Response gains:
```
`region` is the stored value under its name (`homeRegionOut`).
-`regions_available` counts armed candidates; the CLI stays silent below two.
+`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).
-**9.2 `POST /api/cli/apps/{account}/{app}/instances`.** Request unchanged;
-behaviour per §7; response gains `region_from` and `region_name`.
+### 9.2 `POST /api/cli/apps/{account}/{app}/instances`
-**9.3 `GET /api/cli/apps/{account}/{app}/regions`.** Gate and role unchanged
-(`authcli.go:586`). New optional query parameters `tz` and `ip` replace the
-request's own inputs, which is how titogo asks on behalf of its visitor
-rather than its server.
+The request is unchanged, and the behaviour follows §7. The response gains
+`region_from` and `region_name`.
-```json
-{"regions": [
- {"code": "ie", "armed": true, "aws_region": "eu-west-1", "name": "Ireland · Dublin"},
- {"code": "uk", "armed": true, "aws_region": "eu-west-2", "name": "UK · London"}],
- "default": "uk",
- "app_region": "uk", "app_region_source": "timezone",
- "guess": {"code": "ie", "name": "Ireland · Dublin", "source": "ip"}}
-```
+### 9.3 `GET /api/cli/apps/{account}/{app}/regions`
-**(decision) `default` keeps its meaning, "where a create naming no region
-lands", and is now computed.** For every existing app, and for fleet-placed
-and pooled apps, that is still `HomeRegion`, today's value. `guess` is per
-caller, `default` per app: a per-instance picker (titogo) preselects `guess`;
-an app-level display shows `default`. `guess` honours the allow-list (§6.1).
-`name` falls back to the bare code (§13).
+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:
-**9.4 Browser: `GET /a/{sqid}/apps/new/region?tz=…`.** Session-gated through
-`s.account` like the form's GET; returns `{"code", "name", "source"}` for the
-new-app page's script; writes nothing. `new` is a reserved app name
-(`server.go:1150`), so it cannot collide with an app route.
+```json
+"app_default": {"code": "uk", "name": "UK · London", "source": "timezone",
+ "available": false, "reason": "absent"}
+```
-**9.5 Compatibility.**
+`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
| 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; no `region` in the response; CLI prints nothing, or C13 if `--region` was given |
-| titogo, unchanged | new | creates follow the app's region, which for every existing app is the home |
-| new CLI | new | R4 |
+| 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 |
## 10. Console UI
-**New-app page** (`app_new.html`):
-
-- A `<select name="region">` under the name, listing armed candidates by place
- name (C1, C2, C23), preselected with the server's guess. A server render
- cannot see the browser's timezone, so that is IP, then home. The guessed
- option carries C3. Hidden inputs: `tz`, `guess`, `guess_source`.
-- An inline script, progressive enhancement like the account switcher's
- (`templates/layout.html:583`), fills `tz` from
- `Intl.DateTimeFormat().resolvedOptions().timeZone` and, **if the select is
- untouched**, fetches §9.4 and moves the selection and the hidden guess
- fields to the answer. With JavaScript off, the IP-or-home preselection
- stands.
-- Fewer than two armed candidates, or a failed candidate read: no picker, and
- the form is byte-identical to today's. POST then resolves server-side.
-
-**POST** (`handleNewApp`): a `region` in the candidate set is used. Its source
-is `guess_source` when `region == guess` and the word is `timezone`, `ip` or
-`default`, else `chosen`. The word is a label, so forging it changes a log
-line and nothing else. A region outside the armed set re-renders with C5. No
-`region` field (one region, or a form cached from before the roll) means the
-server resolves from `tz`, then IP, then home. The success flash
-(`server.go:3644`) is existing copy and untouched. The audit detail (`:3643`)
-gains the place (C21).
-
-**Instances tab** (Placement section, `app_instances.html:60-78`): when the
-app is not fleet-placed and two or more regions are armed, one line says where
-new instances start (C7) or that they cannot start there now (C8).
-
-**There is no browser instance-create form.** The brief assumed one. The
-app's region reaches instance creation through the door's default (§7), and
-CLI and external pickers read it from §9.3. Building a form is §17 Q5.
+### 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
+ configured.
+
+### 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).
+
+### Instances tab (Placement section, `app_instances.html:60-78`), from `app_default` (§9.3)
+
+- C8 shows **whenever the default cannot accept a create**, however many
+ regions are armed, and names the reason (codex 7).
+- Otherwise C7 shows when the app has a non-home region, or when two or more
+ regions are armed.
+- Pooled and fleet-placed apps show neither line.
## 11. CLI
-Timezone detection is a new helper in the root package, with injected
-`getenv`/`readlink`/`readFile`, using only portable calls so the `20-cross`
-gate builds it for darwin, windows and linux-arm64 with no build tags:
-`$TZ` (leading `:` stripped; a path keeps what follows `zoneinfo/`), else
-`readlink /etc/localtime` after `zoneinfo/` (Linux, and macOS's
-`/var/db/timezone/zoneinfo/…`), else the first line of `/etc/timezone`, else
-nothing. Windows gets nothing and the console falls back to IP (§17 Q15).
+### 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:
+
+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/…`.
+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
| command | flag | meaning |
| --- | --- | --- |
-| `carlos apps create` | `--region <code>` | the app's region; default resolved by the console |
-| `carlos deploy` | `--region <code>` | the region of the app this deploy creates. On an existing app: silent if equal to its region, **refused before anything ships** if different (C15), since the person expected a placement that will not happen. Skipped against an old console (no `app_region` in §9.3). |
-| `carlos canary` | `--region <code>` | the canary's region; default the app's |
-| `carlos instances create` | `--region <code>` | unchanged; help says the default is now the app's region |
-
-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). 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`; no new
-command, so the docsite gate is unaffected.
+| `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 |
+
+### Output and docs
+
+- 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).
+- 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.
## 12. Interactions
@@ -472,45 +658,55 @@ command, so the docsite gate is unaffected.
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 dormant until the placement is
- cleared.
-- **Pools** (`Manifest.Pool`). Boxes are chosen by `poolServed`
- (`internal/provision/reconcile.go:1339`) and the armed set does not know which
- regions serve which pool, so the default is skipped for pooled manifests
- (§7). An explicit `--region` behaves as today.
+ 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 home record with a `replicas` set is filled like any
- other. Nothing stops a home's region equalling one of its own replica
- placements, and defaulting makes that likelier (§17 Q12).
+ refusal stands. 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: where this instance is, and
- where the next one starts, are different questions.
-- **Static.** Not filled; everywhere-class (`reconcile.go:228`).
-- **Private storage (#412).** Unaffected; stores are keyed on
+ `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 (§17 Q10).
+- **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.
+- **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).
## 13. Place names
-**(decision) One table, `internal/geo/awsregions.go`: AWS region code to
-`{latitude, longitude, place}`**, covering the AWS commercial regions as of
-2026-10-01 (C23). Coordinates are the metro area's: AWS publishes no facility
-locations, and tens of kilometres never change a choice between regions. 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, which print the console's `region_name` and
-need no table of their own.
+**(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.
+- 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.
+- 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.
**`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 nor derives it. The table names every armed region consistently,
-including boxes whose operator set nothing, which free text cannot. The two
-should not contradict each other about one box, so the README's
-`STATUS_BOX_REGION` entry gains a sentence recommending the table's name.
-Whether the flagship's values change is §17 Q8.
+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.
## 14. Failure modes
@@ -520,160 +716,209 @@ Whether the flagship's values change is §17 Q8.
| 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` |
-| home publishes no AWS region | every guess `default` |
+| 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 | defaulted creates refused (C16); explicit `--region` works; §9.3 shows `armed: false` |
-| app's region excluded by a later allow-list | refused (C17) |
+| 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` |
-| old console, new CLI with `--region` | C13; app at the home |
-| two consoles disagree | cannot: candidates sort, ties are deterministic, data is embedded |
+| owner adopts an existing app | `""`/`default` unless `region` is explicit |
## 15. Test plan
-**`internal/geo`.** Haversine on known pairs (Dublin to London about 464 km,
-within 5). Nearest: one candidate; a clear winner; an exact tie goes to the
-home; a non-home tie goes to the smaller code; two codes in one AWS region; an
-unarmed candidate nearer than every armed one is never picked; a pointless
-candidate is excluded; a pointless home gives `default`; none gives
-`default`. Timezones: `Europe/London`; `Asia/Calcutta` and `Asia/Kolkata`
-give one point; `Europe/Amsterdam` (in `zone.tab`, not `zone1970.tab`);
-`America/Argentina/Buenos_Aires`; case-insensitivity; and `UTC`, `Etc/UTC`,
-`Etc/GMT+5`, `GMT`, `Factory`, `Local`, `""`, 65 bytes, `../etc/passwd` and a
-link loop are all unmappable. Country points: every multi-row country has an
-override (the test a tzdata refresh trips), and every override names a real
-zone. mmdb: unset, missing path, truncated file, non-mmdb file; a City record
-gives its location, a Country-only record its country point; private,
-loopback and CGNAT addresses never reach the reader (a counting fake proves
-it).
-
-**Store.** Fresh database; one from before the columns; one from before
-`migrateAppsKey` carrying the new ALTERs (the columns survive the rebuild);
-`migrate` run twice. Pre-existing rows read `""`/`""`. `CreateAppInRegion`
-writes both columns in one statement; `CreateAppIn` and `CreateApp` write
-`""`/`""`; rename and transfer keep the region.
-
-**The door** (`apiinstances_test.go`). No region with app region `uk`, armed:
-record `uk`, `region_from` `app`. App region `""`: record byte-identical to
-the pre-change golden. Explicit wins over app; fleet wins over both. Pooled,
-static, addr-backed and replica creates are not filled, and a replica with no
-region gets the existing refusal word for word. Unarmed app region gives C16,
-allow-list exclusion C17, store error 502. A dormant gate accepts app region
-`uk`, as it accepts an explicit `uk` today.
-
-**App create.** API: an old body from a person resolves by IP, then home; a
-`timezone` body; explicit valid, invalid (C18) and unarmed (C19) regions;
-`place` plus `region`; adoption stores `""`; a service principal gets no IP
-step; response fields echo what was stored. Browser: one region renders
-today's form byte for byte; the IP guess is preselected; §9.4 needs a session
-and is 404 to a non-member; `region == guess` keeps the source; a different
-region or a forged `guess_source` records `chosen`; an unarmed region
-re-renders with C5; CSRF unchanged.
-
-**Regions endpoint.** For an existing app, `regions` and `default` match
-today's golden. `name` and `aws_region` present; `tz` and `ip` honoured;
-`guess` honours the allow-list, and is `null` when it excludes the home and
-nothing has a point.
-
-**CLI.** Detection with injected functions: `TZ=":Europe/London"`, a `TZ`
-path, a plain `TZ`, the `/etc/localtime` link on Linux and macOS,
-`/etc/timezone`, nothing. One line per source; silence below two regions;
-silence or C13 against an old console. `deploy --region` on an existing app:
-different is refused before anything ships (prove nothing uploaded), equal is
-silent. Shell-out tests use `carlosTestEnv` and a scratch `cmd.Dir`
-(AGENTS.md). `20-cross` builds the detector everywhere.
+### `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;
+ - two codes in one AWS region;
+ - an unarmed candidate nearer than every armed 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`);
+ - `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
+
+- 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.
+- Pre-existing rows read `""`/`""`.
+- `CreateAppInRegion` writes both columns in one statement, and `CreateAppIn`
+ and `CreateApp` write `""`/`""`.
+- Rename and transfer keep the region.
+
+### The door (`apiinstances_test.go`)
+
+- No region with app region `uk`, armed: 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.
+- 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.
+- 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.
+
+### 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.
+
+### 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.
+- `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. **Console first.** One release carries the migration, `internal/geo`, the
- fill, the endpoints and the UI. Old CLIs and titogo see no change, because
- every existing app reads `""`. Until the new CLI ships, apps created from
- the CLI resolve by IP when a database is configured and otherwise land at
- the home, as today. No box roll, no edges-file change.
-2. **Verify on dev** 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
- `GET …/regions` for an old app against the pre-roll answer.
-3. **Flagship console**, through the usual console roll. Optionally place the
- mmdb and set `CONSOLE_GEOIP_DB` in the deployment repo (§17 Q6).
-4. **CLI release.** Safe in either order (§9.5); the console must go first for
- the lines to appear.
-5. **titogo**, in its own repo, adopts `guess` and the names from §9.3.
-
-Copy review (§18) happens before step 1 ships any string.
-
-## 17. Open questions
-
-1. **This does not unload Ireland by itself.** Existing apps keep the home (by
- ruling). If the 116 instances belong to a few long-lived apps that create
- instances through the API (titogo, Managed Jelly), their next instances
- still land in Ireland. Relief needs new apps, titogo sending `guess` per
- instance, or moves. Is titogo's adoption part of "done"?
-2. **Latency depends on DNS.** Non-static instance hosts are not steered by
- default, and the per-instance DNS seam is recorded, not built
+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: placement improves, latency may get worse. Is
- `CARLOS_STEERING_ZONE_ID` set on the flagship? Should a non-home instance
- get `Steering: latency` by default?
-3. **Refuse or fall back when the app's region goes dark?** §7 refuses, so a
- signup path fails until someone passes a region. The alternative places the
- instance at the home and says so in `region_from`.
-4. **Can an app's region change?** D1 says set once, so a wrong guess (a VPN,
- a laptop abroad) means `--region` on every instance. A later owner-only
- `carlos apps region` would fix that.
-5. **A browser instance-create form.** Build one, or leave instance creation to
- the CLI and the API?
-6. **Which GeoIP database, under which licence?** GeoLite2 needs a MaxMind
- account and forbids redistribution. DB-IP Lite is CC BY 4.0 and requires
- attribution where used, which would be new copy on the new-app page.
-7. **A new Go dependency.** `github.com/oschwald/maxminddb-golang` (ISC), or a
- few hundred lines in `internal/geo`. Test fixtures: MaxMind's test-data
- repository (licence to check) or a test-only writer.
-8. **Place-name style.** The draft says "UK · London", "Ireland · Dublin"; the
- status page's example is "EU · Ireland". Do the flagship's
- `STATUS_BOX_REGION` values (in carloku-infrastructure) change to match?
-9. **The home's coordinates.** §6.1 derives them from boxes with no placement
- code. Confirm the flagship's Ireland box publishes `region: eu-west-1` and
- no `placement`. Is a `CONSOLE_HOME_AWS_REGION` override wanted?
-10. **Sidecars.** `handleAPISidecarCreate` has no armed gate, so filling a
- region there could strand a sidecar where nothing checks. Should sidecars
- follow the app's region, with a gate added?
-11. **Pools.** Skipping pooled apps is safe, but they never benefit. Should
- the armed set learn pools?
-12. **A home in one of its own replica placements.** No rail refuses it
- (`checkReplicaCreate`, `apiinstances.go:570`). Legal?
-13. **Allow-lists and the home's name.** `instanceRegionAllowed` compares the
- normalized `""`, so a manifest listing `ie` refuses creates at an `ie`
- home. Does any production manifest carry `regions`?
-14. **Unnamed home, several regions.** Without `CONSOLE_HOME_REGION` an API
- caller cannot pick the home explicitly (`""` means "no preference").
- Should the console warn at startup in that state?
-15. **Windows.** No timezone is sent (Windows zone names need a CLDR mapping),
- so Windows users get the IP step. Acceptable?
-16. **The AWS region list.** C23 is from memory. The newest rows
- (`ap-east-2`, `ap-southeast-6`, `ap-southeast-7`, `mx-central-1`) need
- checking against AWS's list when the table is built.
+ 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)
-Drafts. None ships until Paul has reviewed it through copy review. No em
-dashes. `{…}` is filled at runtime.
+These are drafts. None ships until Paul has reviewed it through copy review.
+There are no em dashes, and `{…}` 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, guessed option suffix | (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, region not armed | This app's region, {place}, can't take new instances right now. |
+| 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. |
| C9 | CLI, from timezone | Region: {place} (from your timezone). Use --region to choose another. |
| C10 | CLI, from IP | Region: {place} (from your network location). Use --region to choose another. |
| C11 | CLI, fallback | Region: {place} (the platform default). Use --region to choose another. |
| C12 | CLI, chosen | Region: {place}. |
-| C13 | CLI, old console ignored --region | This console does not record an app region yet, so --region was not applied. New instances will start in the platform's default region. |
+| C13a | CLI, old console ignored --region | This console does not record an app region yet, so --region was not applied. |
+| 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. |
@@ -683,6 +928,10 @@ dashes. `{…}` is filled at runtime.
| 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.) |
+| 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. |
C23, place names:
@@ -706,5 +955,39 @@ 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 (§11, §13) are prose and go
-through the same review.
+The `docs/site/cli.md` and README additions (§5.7, §11, §12, §13) are prose
+and go through the same review.
+
+## 19. Review dispositions (Astra, round 1)
+
+| # | 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) |