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