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>
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. |