carlos / platform Public

docs: nearest-region default design spec

Every carloku instance lands on the Ireland home box because no code
chooses a region: 116 of 116 non-static instances, 91% disk, and one
customer crash-looping for 4h18m on 2026-10-01. This spec records Paul's
rulings (per-app region chosen at creation from a timezone-then-IP guess,
the default for new instances, CLI parity) and designs the console-side
column, the resolution algorithm, the create-door fill, every create
path, API and UI changes, interactions, failure modes, tests, rollout,
open questions and draft copy pending review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 7fbc9479bf8bfe95c0b65da67f51bfbaebf0b833 parent e8406ce
1 file changed, +710 −0
  • docs/superpowers/specs/2026-10-01-nearest-region-default-design.md +710 −0
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
new file mode 100644
index 000000000..df4f30cc4
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-01-nearest-region-default-design.md
@@ -0,0 +1,710 @@
+# Nearest region by default: each app picks its region at creation
+
+**Date:** 2026-10-01
+**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.
+**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.
+
+## 1. The problem, as found
+
+On 2026-10-01 the Ireland box carried 116 of the flagship's 116 non-static
+instances and reached 91% disk. Overnight, one customer's three instances
+crash-looped for 4h18m on a full disk. Nothing chose Ireland. Nothing chose
+anything.
+
+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
+ `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
+ and nothing else. The Instances tab's Placement section
+ (`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 `""`.
+- `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`), 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.
+
+## 2. Goals and non-goals
+
+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.
+- 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.
+
+## 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 from `GET …/regions`; 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
+
+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
+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.
+
+## 5. Data model
+
+### 5.1 Columns
+
+**(decision) Two columns, appended to the ALTER list in `migrate`**
+(`internal/console/store.go:91`, list ending `:386`):
+
+```sql
+ALTER TABLE apps ADD COLUMN region TEXT NOT NULL DEFAULT '';
+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`).
+- `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.
+
+**Nothing else is stored**: not the timezone, not the IP, not a coordinate.
+
+### 5.2 The rebuild trap
+
+`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`.
+
+### 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 `region, source` parameters.
+
+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.
+
+Rename (`store.go:1791`) and transfer (`:1709`) update the same row, so the
+region survives both. The legacy owners backfill (`:665`) inserts defaults,
+which mean the home.
+
+### 5.4 Backfill
+
+**(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.
+
+### 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.
+
+## 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}`:
+
+- **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.
+- **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.
+- **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.
+
+### 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. `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.
+
+**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 from
+an explicit `ip` parameter (§9.3).
+
+### 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.
+
+- 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.
+
+### 6.4 Order
+
+```
+explicit region (validated as in §8.1) → "chosen"
+else timezone gives a point → nearest → "timezone"
+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.
+
+### 6.5 Logged, and not logged
+
+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.**
+
+## 7. The instance create door
+
+**(decision) The fill, replacing `apiinstances.go:772`:**
+
+```go
+raw := strings.TrimSpace(req.Region)
+regionFrom := ""
+if raw != "" {
+ regionFrom = "request"
+}
+if raw == "" && placed == "" && m.Pool == "" &&
+ strings.TrimSpace(req.Role) == "" &&
+ (provision.Record{Kind: strings.TrimSpace(req.Kind), Addrs: req.Addrs}).DerivesPaths() {
+ appRegion, _, err := s.store.AppRegion(acct.ID, app)
+ if err != nil {
+ // log; 502 C20
+ }
+ if appRegion != "" {
+ raw, regionFrom = appRegion, "app"
+ }
+}
+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:
+
+| 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).
+
+**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`.
+
+## 8. Every create path
+
+### 8.1 Where an app is created
+
+| 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` |
+| 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) |
+| `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.
+
+### 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` |
+| `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) |
+
+## 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.
+
+**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:
+
+```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; the CLI stays silent below two.
+
+**9.2 `POST /api/cli/apps/{account}/{app}/instances`.** Request unchanged;
+behaviour per §7; response gains `region_from` and `region_name`.
+
+**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.
+
+```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"}}
+```
+
+**(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).
+
+**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.
+
+**9.5 Compatibility.**
+
+| 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 |
+
+## 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.
+
+## 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).
+
+| 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.
+
+## 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 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.
+- **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).
+- **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
+ `(account, app, environment)`.
+- **Sidecars.** Unchanged (§17 Q10).
+
+## 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.
+
+**`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.
+
+## 14. 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` |
+| home publishes no AWS region | every guess `default` |
+| 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-region read fails at the door | 502 (C20); no record written |
+| 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 |
+
+## 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.
+
+## 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
+ (`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.
+
+## 18. Copy (pending copy review)
+
+Drafts. None ships until Paul has reviewed it through copy review. 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, guessed option suffix | (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. |
+| 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. |
+| 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. |
+| 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)". |
+
+C23, place names:
+
+| AWS region | place | AWS region | place |
+| --- | --- | --- | --- |
+| us-east-1 | US East · N. Virginia | eu-north-1 | Sweden · Stockholm |
+| us-east-2 | US East · Ohio | il-central-1 | Israel · Tel Aviv |
+| us-west-1 | US West · N. California | me-south-1 | Bahrain |
+| us-west-2 | US West · Oregon | me-central-1 | UAE |
+| ca-central-1 | Canada · Montréal | af-south-1 | South Africa · Cape Town |
+| ca-west-1 | Canada · Calgary | ap-south-1 | India · Mumbai |
+| mx-central-1 | Mexico · Querétaro | ap-south-2 | India · Hyderabad |
+| sa-east-1 | Brazil · São Paulo | ap-east-1 | Hong Kong |
+| eu-west-1 | Ireland · Dublin | ap-east-2 | Taiwan · Taipei |
+| eu-west-2 | UK · London | ap-southeast-1 | Singapore |
+| eu-west-3 | France · Paris | ap-southeast-2 | Australia · Sydney |
+| eu-central-1 | Germany · Frankfurt | ap-southeast-3 | Indonesia · Jakarta |
+| eu-central-2 | Switzerland · Zurich | ap-southeast-4 | Australia · Melbourne |
+| eu-south-1 | Italy · Milan | ap-southeast-5 | Malaysia |
+| eu-south-2 | Spain · Aragón | ap-southeast-6 | New Zealand · Auckland |
+| 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.