carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Nearest region by default: each app picks its region at creation
2
3**Date:** 2026-10-01
4**Revision:** 6.2.
5
6- Rev 1 (7fbc9479) went to Astra (GPT-6, via codex), and the findings are in
7 `CODEX-FINDINGS-NEAREST-REGION-R1.md`.
8- Rev 2 (bd4ed0c4) went back, and the findings are in
9 `CODEX-FINDINGS-NEAREST-REGION-R2.md`.
10- Rev 3 (8e59f598) went back again, and the findings are in
11 `CODEX-FINDINGS-NEAREST-REGION-R3.md`.
12- Every finding of all three rounds was checked against the code and
13 accepted. Changes are marked "(codex N)", "(codex R2-N)" and
14 "(codex R3-N)", and §21 records each disposition.
15- Rev 3 folded in Paul's answers to rev 2's questions as rulings R5 to R7.
16- **Rev 4 moves latency routing (R6) into a follow-on spec**, because the
17 existing steering converger is not safe to arm (§13). That spec gets
18 invariants to meet, and placement ships first.
19- Rev 4.1 records Paul's ruling on that split as R8 and removes the question.
20- Rev 5 answers Astra's round 4 (`CODEX-FINDINGS-NEAREST-REGION-R4.md`): three
21 P2s, marked "(codex R4-N)". Astra's round 5 found nothing new; Paul
22 approved rev 5 on 2026-10-02.
23- **Rev 6 is an amendment Paul approved on 2026-10-02, ruling R9**, after a
24 production finding: only the Ireland box holds `CARLOS_SECRETS_KEY`. On the
25 UK, US and AU boxes the config loops log "config sync loop disabled:
26 CARLOS_SECRETS_KEY not set" (`internal/host/agent.go:881-886`), and a wake
27 treats a missing `<host>.config.env` as empty
28 (`internal/activator/backend_exec.go:1146-1149`). An instance placed there
29 runs with no config, no secrets and no store binding. That was part of the
30 kpmg outage. Changes are marked "(R9)".
31- **Rev 6.1 records ruling R9a** (Paul, 2026-10-02): `app_default` gains
32 `region_known` and `placement_known`. They are additive, and absent
33 knowledge fails closed. They came out of the implementation plan's review
34 (Astra plan round 2, finding 3), and §9.3 states their semantics.
35- **Rev 6.2 records `instance_domain`** (Paul approved it on 2026-10-03):
36 an additive, read-only field of `app_default`, the authoritative hostname
37 suffix `canary --region` reads its own record under before any write
38 (§9.3). It came out of Astra's plan round 3 (finding 1), and her round 4
39 judged it sound. Rev 6.2 also writes down a consequence of R9a that round
40 4 found (finding 1): with the stored region unread, `effective` is empty
41 even for a pooled or fleet-placed app, so `canary --region` needs both
42 flags.
43
44**Status:** Approved by Paul on 2026-10-02: rev 5, plus the R9 amendment in
45this revision. The rulings in §3 are Paul's and are not reopened here.
46**Discussion:** not yet filed on amadan. Latency routing depends on amadan
47#169.
48**Builds on:**
49
50- #329, the armed-placement gate
51- #346, the home region's name (`CONSOLE_HOME_REGION`)
52- the customer-fleets placement column
53- #162, replica placements
54- #175/#331, instance moves
55- the multi-region steering converger (`internal/steering`)
56
57## 1. The problem, as found
58
59On 2026-10-01 the Ireland box carried 116 of the flagship's 116 non-static
60instances and reached 91% disk. Overnight, one customer's three instances
61crash-looped for 4h18m on a full disk. Nothing chose Ireland. Nothing chose
62anything.
63
64Every create path ends at one door, and that door's default is the home box:
65
66- The instance create door, `handleAPIInstanceCreate`
67 (`internal/console/apiinstances.go:608`), trims `req.Region` and hands it to
68 `homeRegionIn` (`apiinstances.go:772`; the function is at
69 `internal/console/apiinstancemove.go:136`). The home's name normalizes to
70 `""`, and `""` is the unconstrained record only the home box mints.
71- The new-app page (`internal/console/templates/app_new.html`) asks for a name
72 and nothing else. The Instances tab's Placement section
73 (`templates/app_instances.html:60-78`) offers "Shared fleet" or one of the
74 account's own fleets, which is a different axis.
75- `carlos deploy` creates an app only through `offerCreateApp`
76 (`deploy.go:513`, call at `:536`), and sends no region. Its only instance
77 write is the static platform host (`mintPlatformHost`, `deploy.go:850`,
78 create at `:858`), which sends `""`.
79- `carlos canary` creates its host at `canary.go:407` with region `""`.
80- Only `carlos instances create --region` (`instances_cli.go:293`, sent at
81 `:322`) chooses, and only when a person remembers to. External signup paths
82 (titogo, Managed Jelly) hold instance-role tokens and send no region.
83
84And nothing is steered. Latency routing exists (`internal/steering`), but it
85is armed nowhere, because no deployment sets `CARLOS_STEERING_ZONE_ID`
86(amadan #169). Every instance host therefore resolves through the
87deployment's static DNS. On the flagship that is one `*.oncarlos.com` record
88pointing at the Ireland EIP, served by DNSimple. The Route53 copy of the zone
89is staged and is not yet authoritative (carloku-infrastructure `dns.tf` and
90`dns53.tf`).
91
92What exists to build on:
93
94- **Placement codes** are bare (`ie`, `uk`, `au`): `CARLOS_REGION` verbatim.
95- **Place names**: the only one anywhere is `STATUS_BOX_REGION`, operator free
96 text for the public status page (`internal/host/agent.go:88-92`).
97- **The edges file**: every box publishes its AWS region and its placement
98 code there (`internal/host/edgesfile.go:155-176`). The region comes from
99 `edgesFileRegion` (`:537`), which honours a `CARLOS_AWS_REGION` override.
100- **What the console already reads**: both of those, through
101 `edgesFilePlacementView` (`internal/console/instancesplacement.go:35-55`)
102 and `servedBy.Region` (`:25-28`).
103- **Capability**: `placements` and `armedPlacements`
104 (`instancesplacement.go:173`, `:207`) say which codes can host.
105- **The regions endpoint**: `GET /api/cli/apps/{account}/{app}/regions`
106 (`internal/console/apiregions.go:31-55`) returns codes, `armed` and
107 `"default": HomeRegion`, and reads no manifest.
108
109There is no geolocation of any kind.
110
111## 2. Goals and non-goals
112
113Goals:
114
115- A new app gets a region at creation, preselected from a guess of where its
116 creator is. Its new instances start there unless told otherwise.
117- A new instance placed outside the home is reached through the nearest edge,
118 not through the home's (R6). The follow-on spec for Phase L delivers this
119 goal, against the invariants in §13.
120- The guess is generic: any deployment, any set of AWS regions.
121- The guess can never name a region the create door would refuse, or one no
122 box would mint (codex 1, codex R2-1).
123- Existing apps, older CLIs and external callers that send nothing behave
124 exactly as today.
125
126**Done** has two stages:
127
128- **This spec** is done when new apps get regions and new instances follow
129 them.
130- **The work as a whole** (R5, R6) is done when the follow-on spec has made
131 latency routing safe to arm, and #169 is armed.
132
133Existing apps keep their region.
134Relieving Ireland of the instances it already holds is the instance-move
135work (#175/#331), not this spec.
136
137Non-goals, each with a follow-up in §20:
138
139- Arming DNS steering and changing the converger. This is Phase L, the
140 follow-on spec (§13).
141- Moving existing instances.
142- Changing an existing app's region (R5).
143- A browser instance-create form.
144- Sidecars following the app's region.
145- Pool-aware candidates.
146- Box sizing and disk alerts.
147- An account-level default (R1).
148
149## 3. Rulings (Paul, 2026-10-01)
150
151| # | Ruling | Where this spec spends it |
152| --- | --- | --- |
153| 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 |
154| 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 |
155| R3 | Guess **timezone first**. Fall back to IP geolocation when the timezone is UTC, `Etc/*` or otherwise unmappable. | §6 |
156| 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 |
157| 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 |
158| R6 | **Latency routing is in scope** for non-home instances, not placement alone. | §13: its invariants and gates, delivered by the follow-on spec |
159| 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 |
160| R8 | **Split** (Paul, 2026-10-01). Placement ships on this spec. Latency routing (R6) is sequenced to its own follow-on spec, built to the §13 invariants. | §13, §17 |
161| R9 | **A box must hold the deployment's secrets key to receive placed instances** (Paul, 2026-10-02). | §6.1, §7, §8.1, §17 |
162| R9a | **`app_default` gains `region_known` and `placement_known`** (additive; absent knowledge fails closed) (Paul, 2026-10-02). | §9.3 |
163| 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 |
164| 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 |
165| D3 | Surfaces: a new-app picker; names and the guess for external pickers; the CLI as R4. | §9, §10, §11 |
166| D4 | Place names come from one platform-shipped AWS-region table, used everywhere and reconciled with `STATUS_BOX_REGION`. | §14 |
167
168## 4. The shape, in one paragraph
169
170The console's `apps` row gains two columns:
171
172- `region`, in the record's own vocabulary, where `""` is the home;
173- `region_source`, one word saying how it was picked.
174
175Both app-create doors resolve a region before the row is written. The order
176is an explicit choice, then the creator's timezone, then the request's IP
177(through the optional GeoIP database), then the home. Resolving means
178finding the great-circle nearest region whose boxes have positively said
179they can mint an ordinary instance.
180
181From then on, an instance create that names no region gets the app's region,
182and that value meets every check an explicit one meets. An **app-derived**
183value additionally needs that positive eligibility, while an explicit one
184keeps today's gate (codex R3-6).
185
186Latency routing for non-home instances (R6) is specified as invariants
187(§13) and delivered by a follow-on spec. Until that lands, this spec writes
188nothing to DNS and stamps nothing.
189
190Existing rows read `""`, which is what they have always meant. Nothing writes
191a non-home app region until the operator turns the feature on, and the
192console will not turn it on without a named home.
193
194## 5. Data model
195
196### 5.1 Columns
197
198**(decision) Two columns, appended to the ALTER list in `migrate`**
199(`internal/console/store.go:91`, list ending `:386`):
200
201```sql
202ALTER TABLE apps ADD COLUMN region TEXT NOT NULL DEFAULT '';
203ALTER TABLE apps ADD COLUMN region_source TEXT NOT NULL DEFAULT '';
204```
205
206- `region` is a placement code in the **record vocabulary**: `""` is the
207 deployment's home, exactly as on a `provision.Record` (#346). A choice of
208 the home's name is normalized with `homeRegionIn` before storing, and
209 displays use `homeRegionOut` (`apiinstancemove.go:143`).
210- `region_source` is `chosen`, `timezone`, `ip`, `default`, or `""` (the row
211 predates this feature). It is a label for people and logs, and nothing
212 branches on it.
213
214**Nothing else is stored**: not the timezone, not the IP, not a coordinate.
215
216### 5.2 The rebuild trap
217
218`migrateAppsKey` (`store.go:472`) rebuilds `apps` with a fixed column list
219(`:487-505`) and runs **after** the ALTER list. On a database not yet rebuilt,
220the new columns would be added and then dropped by the rebuild's
221`INSERT … SELECT`, and every later read of `region` would fail.
222
223**Both columns go into `apps_new`'s DDL and the INSERT's column list in the
224same change.** This is the rule the comment at `:499-503` already records for
225`placement`, `control_plane` and `build_probe`.
226
227### 5.3 Written with the row
228
229**(decision) The region rides the INSERT that claims the name.**
230
231- A new `CreateAppInRegion(accountID, app, createdBy, region, source, now)`
232 carries both columns.
233- `CreateAppIn` (`store.go:1349`) becomes a call to it with `("", "")`, so
234 `CreateApp` (`:1342`) is unchanged.
235- `claimApp` (`internal/console/server.go:3466`) gains a `regionReq`
236 parameter (§5.5).
237
238This departs from the placement precedent on purpose. `SetAppPlacement` after
239`CreateAppIn` (`server.go:3550-3567`) is best-effort, because `carlos apps
240place` can repair it. An app region has no repair verb (R5), so a failed
241second write would leave the app at the home for good, silently.
242
243Rename (`store.go:1791`) and transfer (`:1709`) update the same row, so the
244region survives both. The legacy owners backfill (`:665`) inserts defaults,
245which mean the home.
246
247### 5.4 Backfill
248
249**(decision) The column default is the backfill.** Every existing row reads
250`""`/`""`. `""` is the record vocabulary's home, so the door's fill (§7) turns
251`""` into `""` and nothing changes, byte for byte.
252
253Writing the home's code instead would be worse on every count:
254
255- It would gain nothing, because `homeRegionIn` normalizes it back to `""`.
256- It would be impossible where `CONSOLE_HOME_REGION` is unset.
257- It would freeze the home as of migration day, where `""` follows the home
258 as records already do.
259
260The backfill is idempotent by construction: the ALTERs swallow "duplicate
261column", and there is no one-shot `PRAGMA user_version` step.
262
263### 5.5 Explicit choice versus guess, and adoption (codex 2, codex R2-3, codex R2-5)
264
265`claimApp` receives a request-local struct, not a resolved region:
266
267```go
268type regionReq struct {
269 explicit string // a person's choice: a code or the home's name; "" = none
270 tz string // a hint
271 ip net.IP // from s.clientIP, set only for a person's request
272}
273```
274
275**Only `explicit` is a choice.** The guess is resolved inside `claimApp`,
276after it knows whether this is an adoption, so no caller can pass a guess off
277as a choice.
278
279`ip` comes from `s.clientIP` (`internal/console/authcli.go:876`). `claimApp`
280has no `*http.Request`, and its `addr` argument is the audit actor
281(`apicli.go:349`), not a network address (codex R2-5). Both callers fill `ip`
282before the call. It lives only for that call, and is never logged or stored.
283
284**(decision) Adoption never changes an existing app's region by guessing.**
285An owner claiming a name whose objects already exist (`adoptingExistingApp`,
286`server.go:3502`, `:3545`) is restoring an app that probably has instances at
287the home. An adoption stores `""` with source `default` unless `explicit` is
288set. A guess, however it was computed and whatever the form displayed, is
289discarded.
290
291The browser form keeps the two apart without trusting any label. The
292preselected option's value is `@auto`, which is outside the placement-code
293grammar (`^[a-z0-9-]{0,16}$`, `provision.go:688`). A deployment with a
294region, or a home, named `auto` therefore still works (codex R2-3). Only a
295region code is `explicit` (§10). The CLI sends `region` only for `--region`,
296and sends `timezone` separately (§9.1).
297
298### 5.6 The switch and the named home (codex 3, codex 4)
299
300**(decision) Non-home app regions are written only when both of these hold:**
301
302- `CONSOLE_APP_REGIONS=on` (new; off by default);
303- `CONSOLE_HOME_REGION` is set.
304
305If the switch is on and the home is unnamed, the console logs that once at
306startup and behaves as if the switch were off.
307
308Without a named home, `""` is the only way to say "home", and `""` also means
309"no preference". No request could then choose the home over a non-home app
310default. A named home makes `"region": "<home name>"` an explicit choice at
311every door.
312
313While the switch is effectively off:
314
315- Every new app gets `""`/`default`. An explicit non-home `region` on app
316 create is not stored, and the response shows what was (§9.1).
317- The new-app picker is hidden.
318- The guess endpoint (§9.4) still answers, because a per-instance guess for
319 titogo writes nothing to the app row.
320- **The door's fill still runs.** Turning the switch off stops new promises.
321 It never breaks the ones already made.
322
323### 5.7 Mixed versions and rollback (codex 4, R7)
324
325The console owns its SQLite database, so one database is served by one
326console at a time. Mixed versions therefore means a rollback, or a roll that
327has not yet reached a deployment's console. A console older than this feature
328ignores the columns, so after a rollback it would land a no-region create for
329a `uk` app at the home.
330
331**(decision, under R7) The rule:**
332
3331. **Roll the read side first.** The first release carries the migration, the
334 fill, the endpoints and the UI, with the switch off. Every app row is then
335 `""`, so the release can be rolled back freely.
3362. **The switch is the feature's rollback lever.** It is turned on only after
337 that release has run cleanly. Turning it off stops new non-home values and
338 keeps the fill.
3393. **Once any app holds a non-home region, the binary rollback floor is the
340 first region-aware release.** This is a runbook rule (R7), with no outside
341 guard. It is written in three places:
342 - the README's "Releasing the platform" section (`README.md:227`);
343 - that release's notes;
344 - the console roll recipe in `carlosframework/carloku-infrastructure`.
345
346 Rolling below the floor is out of policy. If it happens anyway, no-region
347 creates land at the home, as they do today. Nothing is lost.
3484. **On roll-forward, the console logs possible placement discrepancies**
349 (codex R2-6). For each app with a non-home region, it lists records that
350 carry an empty region and were created after the app.
351
352 Records that never inherit the app default are excluded: static,
353 addr-backed, replica, pooled and fleet-placed. Those are the shapes §7
354 leaves unfilled. A deliberate home override also produces an empty
355 region, and the record keeps no provenance, so the list is a set of
356 candidates for a person to review, not a detection of rollback damage.
357 The runbook records the rollback interval, so the review can be narrowed
358 to records created inside it.
359
360CLIs mix freely in both directions (§9.6).
361
362## 6. Resolving a region
363
364### 6.1 Candidates and eligibility (codex R2-1)
365
366**(decision) One function, `regionCandidates(ctx, policy)`, in
367`instancesplacement.go`, built from the same edges-file read `placements`
368uses.** Each candidate is `{code, awsRegion, eligible}`.
369
370**Eligibility is positive and per box.** `Hosting` checks reachability and
371the activator, but not whether a box will mint an *ordinary* instance:
372
373- A box with `CARLOS_POOLS` set serves the shared pool only if the list names
374 it (`poolServed`, `internal/provision/reconcile.go:1339`).
375- A box with `CARLOS_INBOUND=none` refuses every routed instance
376 (`reconcile.go:925`).
377
378Neither fact is in the edges file today. **(decision) Each box publishes two
379additive fields** in `edgesFile` (`internal/host/edgesfile.go:155-176`):
380
381- `serves_shared_pool`: `poolServed(cfg.Pools, "")`;
382- `accepts_inbound`: `cfg.Inbound != InboundNone`.
383
384**Both are computed from the very `provision.ReconcileConfig` the reconcile
385pass uses**, which `provisionTickConfig` builds (`internal/host/agent.go:2030`).
386They are never re-parsed from the environment (codex R3-3). That parser
387drops empty elements, so `CARLOS_POOLS=,tito` is `["tito"]` and does **not**
388serve the shared pool. The box publishes `false` for it. A box that should
389serve both the shared pool and a named one needs a parser change with its
390own tenancy tests, which is not part of this spec.
391
392They follow `hibernation_configured`'s precedent: not `omitempty`, so absent
393means "unknown" and false means "no". This is the one box-side change in this
394spec. It is purely additive, and old readers ignore it.
395
396A code is **eligible** when at least one reachable box publishing it has
397`hibernation_configured`, `serves_shared_pool` and `accepts_inbound` all
398**positively true**. Unknown is not yes here. That is the reverse of
399`placements`' generosity, and deliberately so: that generosity is right for
400an explicit request, which a person made and can see refused. It is wrong for
401an automatic one.
402
403Until boxes roll with the fields, no coded region is eligible. The guess then
404picks only the home, so the feature cannot outrun the box roll.
405
406**The secrets key (R9).** An exec instance's config, secrets and store
407binding reach it only through `<host>.config.env`, which the agent renders
408only when the box holds `CARLOS_SECRETS_KEY` (`agent.go:881-886`, `:1812`).
409A wake treats a missing file as empty (`backend_exec.go:1146-1149`), so a box
410without the key runs a placed instance with nothing, silently. Eligibility
411therefore gains a fourth fact.
412
413- **What a box publishes.** One more edges-file field, `secrets_key_fp`: the
414 fingerprint of the public half of the box's `CARLOS_SECRETS_KEY`, never the
415 key. The name, its place beside `placement` and `data_bucket`, and its
416 shape (additive, `omitempty`, absent when the box holds no key or a key
417 that does not parse) are the move-any-app spec's
418 (`2026-10-02-move-any-app-design.md` §6, on `design-moves-any-region`
419 at e41c4fef). Both features read one field.
420- **The format**, which that spec names but does not fix, is fixed here for
421 both: `"sha256:"` followed by the lowercase hex SHA-256 of the public key's
422 uncompressed SEC1 bytes (`(*ecdh.PublicKey).Bytes()`, 65 bytes for P-256).
423 Those are exactly the bytes `CARLOS_SECRETS_PUBKEY` base64-decodes to, so a
424 box (from its private key) and the console (from the public key) compute the
425 same string. One helper, `appconfig.KeyFingerprint`, is the only code that
426 renders it. The move-any-app spec should cite it rather than define a
427 second one.
428- **What the console expects.** The console learns the deployment's
429 fingerprint from its own `CARLOS_SECRETS_PUBKEY` (`Config.SecretsPub`,
430 `cmd/console/main.go:467-490`). That is the key the CLI seals config to,
431 so it is by definition the one a box must be able to open. The move-any-app
432 spec's console makes the same choice. The console never learns the
433 fingerprint from a box: a "majority of boxes" or "the home box" rule would
434 certify whatever key happened to be widespread, including a wrong one.
435 With `CARLOS_SECRETS_PUBKEY` unset, the console cannot judge. It logs that
436 once at startup, and no coded region is eligible for automatic placement.
437- **The rule.** A code is eligible only if at least one box that meets the
438 other three facts also publishes a `secrets_key_fp` equal to the
439 console's. The home stays exempt, as it is from every eligibility fact:
440 it is where every legacy create already lands, and on the flagship it is
441 the one box that holds the key.
442- **The reason word.** A code that fails only this fact reports
443 `no-secrets-key`, between `not-hosting` and `not-eligible` in the order of
444 checks (§9.3, C16).
445
446Candidate rules:
447
448- **Coded regions**: boxes whose `Placement` passes `provision.ValidRegion`.
449 Fleet codes (`fleet/<name>`) fail on the `/`.
450- **The home**: boxes that publish no placement, or the home's name, and have
451 a dial address. The home is exempt from the positive check, because it is
452 where every legacy create already lands. `handleAPIRegions` already treats
453 it as always armed (`apiregions.go:42-47`).
454- **AWS region**: the `Region` of the eligible boxes behind the code. If they
455 disagree, the most common wins, ties go to the lexically smallest, and the
456 disagreement is logged once.
457- **Policy**, read with `provision.GetManifest` directly (codex 6). This is
458 never `instanceManifest`, which answers 409 or 502 for the caller
459 (`apiinstances.go:117`).
460 - *absent*: no allow-list and no pool.
461 - *known*: candidates pass through `instanceRegionAllowed`
462 (`apiinstances.go:1797`) on the **normalized** code. **A pooled manifest
463 yields no candidates and no guess** (codex 1).
464 - *unreadable*: no guess, and an explicit error at the guess endpoint.
465- **Fleet placement** (codex R4-1). The instance guess endpoint (§9.4)
466 resolves `appPlacement` (`internal/console/fleets.go:479`) first. **A
467 fleet-placed app gets `"guess": null`**, because the door replaces any
468 submitted region with the fleet (`apiinstances.go:798`), so a guess could
469 only mislead a picker. A failed placement read is an explicit error there
470 (C27), never an unrestricted guess. App-creation inference (§5.5) is
471 unaffected: it chooses the stored app region, which stays dormant while the
472 app is placed.
473- **Read failure** (codex R2-2): the listing failing is distinct from "no
474 file publishes a placement". `readEdgesFiles` (`instancesplacement.go:83`)
475 swallows the first today. A new `readEdgesFilesStrict` returns the error.
476 Candidates built after a failed read are the home alone, if the policy
477 admits it (§6.4), and the door treats the failure as unconfirmed (§7).
478
479### 6.2 Points
480
481A point is a latitude and longitude held in memory for one request.
482
483**Timezone.** *(decision)* A new package, `internal/geo`, embeds three files
484from one IANA tzdata release:
485
486- `zone.tab`, one row per country and zone, with ISO 6709 coordinates;
487- a link table generated from tzdata's `Link` lines;
488- a `VERSION` stamp.
489
490`zone.tab` is used rather than `zone1970.tab` because browsers report names
491that `zone1970.tab` has merged away. For example, `Europe/Amsterdam` has its
492own row in `zone.tab` and none in `zone1970.tab`. The links matter because
493Chrome reports `Asia/Calcutta` where Firefox reports `Asia/Kolkata`. Go's
494`time/tzdata` carries no coordinates, so it cannot be the source.
495
496The files are **kept current** by `scripts/update-tzgeo.sh <version>`, which
497fetches the release from `data.iana.org`, checks its signature, and rewrites
498the three files. Running it is a release-checklist step. A stale table only
499misses zone names newer than it, and those fall through to IP.
500
501Lookup:
502
5031. A shape check: at most 64 bytes, one to three `/`-separated segments of
504 `[A-Za-z0-9_+-]`.
5052. Unmappable by rule: `""`, `Local`, `UTC`, `UCT`, `GMT`, `Universal`,
506 `Zulu`, `Factory` and all of `Etc/`.
5073. Links, at most 4 hops.
5084. `zone.tab`, case-insensitive.
509
510A miss at any step makes the timezone unmappable.
511
512**IP.** *(decision)* `CONSOLE_GEOIP_DB` names an operator-supplied `.mmdb`.
513It is read in `cmd/console/main.go` beside `CONSOLE_HOME_REGION`
514(`main.go:278`) into a new `Config.GeoIP`.
515
516- **Recommended source**: DB-IP Lite (CC BY 4.0). Its attribution appears on
517 the page that uses it (C24) and in the README. MaxMind GeoLite2 also works,
518 under its EULA's conditions, which include attribution and keeping the data
519 updated. The operator owns that compliance.
520- **Unset**: the IP fallback is off, which is the default.
521- **Unopenable**: logged once, the fallback is off, and **the console still
522 starts**.
523- **Lifecycle**: the file is opened once and memory-mapped. Replacing it is an
524 atomic rename plus a console restart.
525- **Lookup**: the `location` field, else `country.iso_code`, else
526 `registered_country.iso_code`, through country points. A lookup error is a
527 miss, logged at most once a minute.
528- **Never looked up**: loopback, private, link-local, CGNAT and unspecified
529 addresses.
530
531The reader is `github.com/oschwald/maxminddb-golang` (ISC), pinned to a
532reviewed version. The tests use pinned fixtures from MaxMind's `MaxMind-DB`
533test data (MIT or Apache-2.0).
534
535**Country points.** *(decision)* These come from the same `zone.tab`, not a
536second dataset.
537
538- A one-row country uses its row.
539- A multi-row country uses a shipped override naming its principal zone:
540 `US` → `America/New_York`, `AU` → `Australia/Sydney`, `CA` →
541 `America/Toronto`, `RU` → `Europe/Moscow`, `BR` → `America/Sao_Paulo`,
542 `CN` → `Asia/Shanghai`, and so on.
543- A test fails when a tzdata refresh gives a country a second row with no
544 override.
545
546**Whose IP.** The client IP from `s.clientIP` reaches the guess only for a
547person's request (§5.5). A service or app principal is a server, so it gets
548the IP step only by sending an address in the guess body (§9.4).
549
550### 6.3 Nearest
551
552Each eligible candidate's AWS region maps to a point through the region table
553(§14). Distance is haversine (mean Earth radius 6371.0088 km), rounded to
554whole kilometres.
555
556- A candidate with no point is left out and logged once per code.
557- **If the home has no point, the guess is `default`.**
558- **Ties** go to the home, then to the lexically smallest code.
559- An ineligible candidate is never picked.
560
561### 6.4 Order
562
563```
564explicit region (validated as in §8.1) → "chosen"
565else timezone gives a point → nearest → "timezone"
566else IP gives a point → nearest → "ip"
567else home → "default"
568```
569
570**The guess invariant** (codex R3-4): every non-null guess, including every
571fallback to the home, passes the same normalized policy predicate the door
572applies (`instanceRegionAllowed` on the `homeRegionIn` form). The steps run in
573this order:
574
5751. Filter candidates by policy (§6.1).
5762. Pick the nearest among the filtered candidates that have a point.
5773. If the home has no point, or nothing maps, the fallback is the home, **but
578 only if the home is itself a filtered candidate**.
5794. Otherwise the answer is `"guess": null`.
580
581So a manifest that allows only `uk`, with the home's coordinates missing,
582gives `uk` when the hint maps and `null` when it does not, never the home.
583There is also no guess when the policy is pooled or unreadable.
584
585### 6.5 Logged, and not logged (codex 8)
586
587- One line per app creation:
588 `console: app region <sqid>/<app>: <code> (<source>)`.
589- Once per process: an unusable `CONSOLE_GEOIP_DB`; a code with no point;
590 boxes disagreeing on a code's AWS region.
591
592**The timezone, the IP and any coordinate never reach a URL, a log line, an
593audit row, a response body or the database.** They travel only in POST
594bodies. The canonical-host redirect copies the whole query string into its
595`Location` header and its HTML (`server.go:1306`, `:1326`). A 308 on a POST
596keeps the body out of both.
597
598## 7. The instance create door
599
600**(decision) The fill, replacing `apiinstances.go:772`:**
601
602```go
603raw := strings.TrimSpace(req.Region)
604regionFrom := ""
605if raw != "" {
606 regionFrom = "request"
607}
608if raw == "" && placed == "" && m.Pool == "" &&
609 strings.TrimSpace(req.Role) == "" &&
610 (provision.Record{Kind: strings.TrimSpace(req.Kind), Addrs: req.Addrs}).DerivesPaths() {
611 appRegion, _, err := s.store.AppRegion(acct.ID, app)
612 if err != nil {
613 // log; 502 C20
614 }
615 if appRegion != "" {
616 raw, regionFrom = appRegion, "app"
617 }
618}
619region := s.homeRegionIn(raw)
620```
621
622**Where it sits.** After `appPlacement` (`apiinstances.go:763`), and before
623`checkReplicaCreate` (`:781`), the allow-list (`:799`) and the armed gate
624(`:827`).
625
626**Every check an explicit region meets, a defaulted one meets too** (R2). An
627explicit home name is non-empty, so it never triggers the fill, and
628`homeRegionIn` then turns it into `""`. That is the override that always
629exists once §5.6 holds (codex 3).
630
631Each condition earns its place:
632
633| condition | why the default stays out |
634| --- | --- |
635| request names a region | an explicit choice wins (R2) |
636| `placed != ""` | a fleet placement already governs every instance's region (`:785`) |
637| `m.Pool != ""` | a defaulted region with no box serving the pool mints nowhere |
638| `req.Role != ""` | a replica must name its placement; filling it would turn "role replica needs a placement" (`:578-581`) into a silent acceptance |
639| not `DerivesPaths()` | only a socket-backed instance has a process and data that region places |
640| store read fails | refused, as the placement read above it is (`:764-768`) |
641
642**An app-derived region needs positive eligibility** (codex R2-2). The armed
643gate goes dormant when no file publishes a placement (`:848`). That
644dormancy stays for explicit requests and for the legacy home default. For
645`regionFrom == "app"` it does not apply. The door reads the edges files
646strictly (§6.1) and refuses in three cases:
647
648- **The read failed**: a 503 with C28. The region could not be confirmed,
649 which is different from "absent".
650- **No eligible box publishes the code**: C16, naming `absent`, `not-hosting`,
651 `no-secrets-key` (R9) or `not-eligible`. This covers the last box in a region being retired, and
652 every box losing its placement code.
653- **The allow-list excludes it**: C17.
654
655The armed gate's own checks then run as before.
656
657*(decision)* **A failing defaulted region is refused, not quietly replaced by
658the home.** The way out is always an explicit region, the home's name
659included, and `app_default` (§9.3) and the Instances tab (§10) show the
660refusal before anyone tries (codex 7).
661
662*(decision)* **A defaulted home whose region is in its own `replicas` set is
663refused** (C26). Explicit requests keep today's behaviour.
664
665*(decision, R9)* **An explicit request into a region whose boxes provably
666lack the deployment's secrets key is refused** (C31, 400), for a
667socket-backed instance (`DerivesPaths`) and for a replica. Static and
668addr-backed records need no config on the box and are unaffected.
669
670- **What "provably" means.** The console's `CARLOS_SECRETS_PUBKEY` is set,
671 and every reachable, hosting box publishing the code is a **current
672 writer** (it publishes `accepts_inbound`, which the same box release adds
673 and which is never omitted). Among those boxes, none publishes a matching
674 `secrets_key_fp`. A current writer that publishes no fingerprint holds no
675 key, because the field is omitted exactly then.
676- **Unknown keeps today's behaviour.** An old writer, or a console with no
677 `CARLOS_SECRETS_PUBKEY`, cannot prove a mismatch, so the request proceeds
678 as it would today. Roll order therefore never refuses a working region.
679- **Why refuse, and not keep today's behaviour.** The record this would
680 write is an instance that starts, answers health checks, and runs with no
681 config, no secrets and no store, which is the kpmg outage. It belongs with
682 the existing "reachable but cannot host" refusal (`apiinstances.go:827-852`,
683 #110). That one also refuses an explicit request the box would accept but
684 could never serve. A person who named the region learns why at the door,
685 not from a broken app.
686
687**Response.** `apiRecordView` (`apiinstances.go:535`) gains two fields that
688sit outside the record, like `PrivateStoreBucket`:
689
690- `region_from`: `request`, `app` or `fleet`, omitted for the home default;
691- `region_name` (§14).
692
693## 8. Every create path
694
695### 8.1 Where an app is created
696
697| path | file:line | change |
698| --- | --- | --- |
699| 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 |
700| 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) |
701| 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` |
702| The claim | `claimApp`, `server.go:3466`; insert at `:3550` | takes `regionReq`; resolves once it knows whether this is an adoption; `CreateAppInRegion` (§5.3) |
703| `carlos apps create` | `runAppsCreate`, `apps.go:442`; call at `:497`; print at `:502` | `--region`; sends the timezone; prints the region line (§11) |
704| `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 |
705| CLI client | `apiSession.createApp`, `apimode.go:994`; `apiCreateAppResult`, `:958` | two `omitempty` fields in, four tolerant fields out |
706| Legacy owners backfill | `BackfillOpsMemberships`, `store.go:623`; insert at `:665` | none: the defaults mean the home |
707
708*(decision)* An explicit region at app create is validated the way the
709instance door validates one:
710
7111. `ValidRegion`.
7122. Armed and hosting, unless the gate is dormant.
7133. Not provably missing the secrets key (R9, the same rule as §7), refused
714 with C31.
7154. The home's name is accepted through `homeRegionIn`.
716
717Refusals list what would pass (C18, C19, C31). There is no allow-list yet, because
718there is no manifest yet. A fleet `place` and a `region` may both be sent,
719and both are stored (§12).
720
721### 8.2 Where an instance is created
722
723| path | file:line | today | change |
724| --- | --- | --- | --- |
725| The door | `handleAPIInstanceCreate`, `apiinstances.go:608`; region at `:772` | `""` means the home | the fill and eligibility (§7) |
726| `carlos instances create` | `instances_cli.go:322` | sends `--region` or `""` | help text; prints `region_name` (§11) |
727| `carlos canary` | `canary.go:407` | sends `""` | the door fills the app's region; a new `--region`, with the existing-canary rule (§11) |
728| `carlos deploy` static host | `mintPlatformHost`, `deploy.go:858` | static, `""` | none |
729| First-route domain attach | `internal/console/domainattach.go:178` | static, no region | none |
730| External callers (titogo, jam) | `POST …/instances`, `internal/console/authcli.go:613` | usually `""` | follow the app's region; can ask for a guess (§9.4) |
731| Sidecar create | `handleAPISidecarCreate`, `internal/console/apisidecars.go:257` | `""` or explicit | none (§12) |
732
733## 9. API changes
734
735All changes are additive: new request fields are optional and new response
736fields can be ignored. `readJSON` is not strict (`authcli.go:932-947`), so an
737old console ignores the new request fields instead of refusing them.
738
739**9.1 `POST /api/cli/apps/{account}/new`.** The request gains two optional
740fields:
741
742- `region`: an explicit choice, as a code or the home's name;
743- `timezone`: a hint, used only when there is no `region`, and never stored.
744
745The response gains these fields, echoing what was **stored**:
746
747```json
748{"region": "uk", "region_name": "UK · London", "region_source": "timezone",
749 "regions_available": 3}
750```
751
752- `regions_available` counts eligible candidates.
753- With the switch off, an explicit non-home `region` is answered with the
754 home and `default`, and the CLI says so (C13b).
755
756**9.2 `POST /api/cli/apps/{account}/{app}/instances`.** The request is
757unchanged and behaviour follows §7. The response gains `region_from` and
758`region_name`.
759
760**9.3 `GET /api/cli/apps/{account}/{app}/regions`.** The gate, the role
761(`authcli.go:586`) and the legacy fields are unchanged (codex 6):
762
763- `regions` is still the observed topology, unfiltered.
764- `default` is still `HomeRegion`.
765- Each entry gains `aws_region` and `name`.
766
767The response gains one block:
768
769```json
770"app_default": {
771 "region": "uk", "name": "UK · London", "source": "timezone",
772 "effective": "uk",
773 "available": false, "reason": "absent",
774 "region_known": true, "placement_known": true,
775 "instance_domain": "bab.oncarlos.com"
776}
777```
778
779- `region` is the app's **stored** region under its name, always. It is the
780 only field `deploy --region` compares against (codex R2-4).
781- `effective` is where a create that names no region actually lands:
782 - the stored region;
783 - the home, for an app with no region of its own or a pooled app;
784 - the fleet placement itself (`fleet/acme`), for a fleet-placed app, never
785 the platform home (codex R2-4).
786- `available` and `reason` describe `effective`:
787 - `absent`: the code is reported even though `regions` does not list it
788 (codex 7);
789 - `not-hosting`;
790 - `no-secrets-key` (R9, §6.1);
791 - `not-eligible` (§6.1);
792 - `not-allowed`;
793 - `pooled`;
794 - `fleet`: `available` is omitted, because the fleet's own rail decides;
795 - `policy-unreadable`: `available` is omitted;
796 - `edges-unreadable`: `available` is omitted.
797- An absent manifest is not an error, and no query parameters are added
798 (codex 8).
799- **(R9a) Two knowledge flags.** `/regions` always answers 200, so these
800 say which facts behind `app_default` the console actually read:
801 - `region_known` is true exactly when the app's stored region was read.
802 When it is false, `region`, `name`, `source` and `effective` are empty
803 (never the home's name standing in for an unread value), and
804 `available` is omitted.
805 - `placement_known` is true exactly when the app's manifest and its fleet
806 placement were read. When it is false, `reason` is `policy-unreadable`
807 and `available` is omitted.
808 - They separate certainty about the stored region from certainty about
809 the placement policy, because the two CLI checks need different facts.
810 - The R9a emptiness holds whatever the placement says: for a pooled or
811 fleet-placed app whose stored region was not read, `effective` is still
812 empty and `available` still omitted, and `reason` (`pooled`, `fleet`)
813 is the only placement fact stated (rev 6.2, Astra plan round 4,
814 finding 1).
815 - **Absent knowledge fails closed.** A client treats a flag that is false,
816 or missing from an `app_default` block, as unknown, and refuses the
817 check that needs it. `deploy --region` needs `region_known`; refused,
818 it prints C15c. `canary --region` needs `placement_known` and, because
819 its fleet check reads `effective`, `region_known` too (rev 6.2);
820 refused, it prints C30b.
821 - Both flags are additive. A console too old to send `app_default` at
822 all is handled as before (§9.6, §11): deploy skips its check, and
823 canary refuses `--region`.
824- **(rev 6.2) `instance_domain`.** Additive and read-only: the domain the
825 app's instance hosts take, so a client can name an instance's host before
826 writing anything. It is the manifest's `domain` when a manifest exists,
827 else the domain the instances-manifest PUT would stamp
828 (`<sqid>.<PLATFORM_APPS_DOMAIN>`), and it is omitted when the manifest is
829 unreadable or the console has no valid apps domain.
830 - It is the authoritative hostname suffix for `canary --region`'s
831 preflight: the CLI builds `<name>.<app>.<instance_domain>` and reads that
832 one record (`GET …/instances/{host}`) before any write, because the
833 capped instance list skips records it cannot read. A 404 means no
834 canary yet; any other failure, or no `instance_domain`, refuses with
835 C30c.
836 - The CLI then checks the instances-manifest PUT's answer against the
837 preflight: the host built from the domain that PUT returns must equal
838 the host it read. If it does not, it stops with C30c before writing any
839 record.
840
841**9.4 `POST /api/cli/apps/{account}/{app}/regions/guess`.**
842
843- Same gate and role as §9.3.
844- The body is `{"tz": "...", "ip": "..."}`, both optional. A person's request
845 with no `ip` uses its own address, and a service principal's never does.
846- The answer is `{"guess": {"code", "name", "source"}}` or `{"guess": null}`.
847 It is `null` for a pooled or fleet-placed app (§6.1) (codex R4-1).
848- An unreadable manifest, or an unreadable fleet placement, gets a 502 with
849 C27.
850- It writes nothing.
851
852**9.5 Browser: `POST /a/{sqid}/apps/new/region`.**
853
854- A form body with `csrf` and `tz`.
855- Gated by session through `s.account`.
856- Returns `{"code", "name", "source"}`.
857- Writes nothing.
858- `new` is a reserved app name (`server.go:1150`).
859
860**9.6 Compatibility and mixed versions.**
861
862| client | console | result |
863| --- | --- | --- |
864| old CLI | new | No `timezone` is sent, so IP for a person, then home. Never an error. |
865| new CLI | old | The response has no `region`. The CLI prints nothing, or C13a if `--region` was given. |
866| new CLI | new, switch off | The app goes to the home. With `--region`, C13b. |
867| titogo, unchanged | new | Creates follow the app's region, which for every existing app is the home. `default` is unchanged. |
868| any | rolled back below the floor | R7: no-region creates land at the home, and roll-forward lists the possible discrepancies (§5.7). |
869
870## 10. Console UI
871
872**New-app page** (`app_new.html`), when the switch is on and two or more
873regions are eligible:
874
875- **The picker.** A `<select name="region">` whose first option has the value
876 `@auto` and the label C3, naming the server's guess (IP, then home). Every
877 eligible region follows, with its code as the value and its place name as
878 the label (C1, C2, C23). A hidden `tz` field rides along.
879- **`@auto` is not a region** (codex 2, codex R2-3). Selecting a code is the
880 person's choice, and leaving `@auto` asks the server to guess.
881- **The script.** An inline progressive-enhancement script, in the style of
882 the account switcher (`templates/layout.html:583`), fills `tz`, POSTs it to
883 §9.5, and relabels only the `@auto` option.
884- **Otherwise** there is no picker, and the form is byte-identical to today's.
885- **Attribution.** DB-IP attribution (C24) shows when the IP step is
886 configured.
887
888**POST** (`handleNewApp`):
889
890- An eligible code is `explicit`.
891- `@auto`, or no `region` field at all, makes the server resolve from `tz`,
892 then the request's IP, then the home. On an adoption that resolves to `""`.
893- Any other value re-renders the form with C5.
894- The success flash (`server.go:3644`) is existing copy and is untouched. The
895 audit detail (`:3643`) gains the place (C21).
896
897**Instances tab** (the Placement section, `app_instances.html:60-78`), from
898`app_default`:
899
900- C8 shows whenever the default cannot accept a create.
901- Otherwise C7 shows when the app has a non-home region, or when two or more
902 regions are eligible.
903- Pooled and fleet-placed apps show neither line.
904
905## 11. CLI
906
907**Timezone detection** is a helper in the root package, with `getenv`,
908`readlink` and `readFile` injected and only portable calls, so `20-cross`
909builds it everywhere. It tries, in order:
910
9111. `$TZ`, with a leading `:` stripped; a path keeps what follows `zoneinfo/`.
9122. `readlink /etc/localtime`, keeping what follows `zoneinfo/`.
9133. The first line of `/etc/timezone`.
9144. Nothing.
915
916On Windows only a valid `$TZ` is sent. Otherwise the console falls back to
917IP, then the home.
918
919| command | flag | meaning |
920| --- | --- | --- |
921| `carlos apps create` | `--region <code>` | The app's region. The default is resolved by the console. |
922| `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. |
923| `carlos canary` | `--region <code>` | The canary's region. The default is the app's. Before either branch, the CLI reads `app_default.effective` (§9.3). **If the app is fleet-placed, `--region` is refused before anything is created, rechannelled or uploaded, on the first run and on every repeat** (C30) (codex R4-2): the fleet governs every instance's region, so the flag could never be honoured, and refusing it the same way both times keeps the command's answer independent of whether the canary exists yet. Otherwise, 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). Without `--region`, a fleet-placed app's canary is created and reused exactly as today. |
924| `carlos instances create` | `--region <code>` | Unchanged. The help says the default is now the app's region. |
925
926Output:
927
928- After "created app …" (`apps.go:502`, `deploy.go:540`), one line per
929 source, C9 to C12, printed only when `regions_available >= 2`.
930- `instances create` and `canary` add the place to their existing line (C14).
931- No prompts (R4).
932
933`docs/site/cli.md` gains the new flags and the new default. There is no new
934command.
935
936## 12. Interactions
937
938- **Customer fleets.** `appPlacement` (`internal/console/fleets.go:479`)
939 overrides the request's region (`apiinstances.go:785-797`). The precedence
940 is explicit: **fleet placement, then the request's region, then the app's
941 region, then the home.** A placed app's region is stored, stays dormant,
942 and is reported separately from its placement (§9.3). A placed app gets no
943 instance guess (§6.1), and `canary --region` refuses on it consistently
944 (§11) (codex R4-1, R4-2).
945- **Pools** (`Manifest.Pool`). There is no default fill and no guess, and
946 `app_default` reports `pooled`. An explicit `--region`
947 behaves as today.
948- **Pool-restricted and no-inbound boxes** (codex R2-1) are never eligible
949 for an automatic choice (§6.1). Explicit requests keep today's gate.
950- **Replicas (#162).** A `role: replica` create is never filled. A
951 defaulted home inside its own `replicas` set is refused (§7).
952- **Moves (#175/#331).** A move rewrites one record's `region` at its cut.
953 **It never touches the app's region**. Steering for moved instances is the
954 follow-on spec's, derived from region (§13.1).
955- **Static.** Not filled. Its existing latency default is subject to the
956 §13.3 gate like everything else steered.
957- **Private storage (#412).** Unaffected.
958- **Sidecars.** Unchanged. Their door has no armed gate, so following the app
959 region needs its own design (§20).
960- **Allow-lists and the home's name.** `instanceRegionAllowed` compares the
961 normalized `""`, so a manifest listing the home's code refuses creates at
962 the home today. This change keeps that and reinterprets no manifest. The
963 README documents it, and rollout audits production manifests first (§17).
964
965## 13. Latency routing (R6): the requirements Phase L must meet
966
967### 13.1 Why this is now a separate spec (codex R3-1, R3-2, R3-5)
968
969Rev 3 treated latency routing as two small things: a stamp on each non-home
970record at mint, and arming the existing converger (#169). Round 3 showed that
971the converger itself is not ready to arm. Each of these three faults was
972checked against the code:
973
974- **Arming can break DNS for names nobody steered** (codex R3-1). The flagship
975 answers every tenant name with one `*.oncarlos.com` record pointing at the
976 Ireland EIP (carloku-infrastructure `dns53.tf` and `dns.tf`; the platform
977 README documents the same single wildcard at `README.md:442`). The converger
978 writes A records at exact names (`internal/steering/steering.go:369`, `:386`).
979 One latency record at `new.bap.oncarlos.com` creates the empty non-terminal
980 `bap.oncarlos.com`. From then on, `old.bap.oncarlos.com` finds `bap` as its
981 closest encloser, looks for `*.bap.oncarlos.com`, which does not exist, and
982 no longer matches `*.oncarlos.com` (RFC 4592 §3.3.1). Every unsteered
983 sibling under that account stops resolving. Static platform hosts have been
984 stamped `latency` at mint for weeks (`apiinstances.go:936`), so **arming #169
985 as the code stands would do this to every account with a static site.** It
986 would happen with or without this spec.
987- **Two edges in one AWS region produce an invalid record set** (codex R3-2).
988 `desiredRRSet` keys each record by box (`SetIdentifier`) but stamps it with
989 the edge's AWS region (`internal/steering/route53.go:597`). Route53 allows
990 one latency record per region within a name and type. A second London box
991 makes every batch fail, on every pass. This spec allows several placement
992 codes in one AWS region (§6.1), so the topology is reachable.
993- **Large withdrawals never complete** (codex R3-5). When a pass would delete
994 more than the shrink limit, it refuses **every** change and returns
995 (`route53.go:207`). The loop's own notes say this repeats forever until an
996 operator steps in (`internal/host/steering_loop.go:154`). Rev 3's claim that
997 deletes "spread over passes" was wrong.
998
999Behind these sits a fourth fact. The flagship's Route53 zone for
1000`oncarlos.com` is marked "STAGED, NOT YET AUTHORITATIVE" in `dns53.tf`, and
1001DNSimple still serves the live answers (`dns.tf`). A converger writing to a
1002zone nobody consults is inert at best.
1003
1004The arming hazard was posted to amadan discussion carlos/platform #169 on
10052026-10-01, so it is on record where the arming decision lives, independent
1006of this spec.
1007
1008Three rounds have each found a new fault in this one area. Per the lesson this
1009repo has already paid for, the answer is not another patch to an example. It
1010is an invariant, a design built to it, and a simulation that checks it.
1011That is a spec of its own, about the converger. It is not a section of a
1012spec about choosing regions.
1013
1014**(ruling R8) Phase L moves to a follow-on spec, and placement ships without
1015waiting for it.** R6 is unchanged: latency routing for non-home instances is
1016still in scope for this work and still binding. It is sequenced, not dropped.
1017This spec now does three things for R6:
1018
10191. It **writes no stamp.** Rev 3's mint-time `Steering: "latency"` is
1020 withdrawn. Every stamped record would join the set of names whose DNS
1021 changes when the zone is armed, and that set is exactly the danger above.
1022 The follow-on spec should derive "steer this host" from the record's
1023 region, not from a stamp: any socket-backed home record with a non-home
1024 region, in the shared pool and the platform namespace. That covers
1025 instances created before it lands, and instances moved out of the home,
1026 with no backfill and no change to the move code.
10272. It **states the invariants** that spec must meet (§13.2).
10283. It **gates arming #169** on that spec (§13.3).
1029
1030### 13.2 Invariants for the follow-on spec
1031
1032The DNS model is the authoritative zone for the apps domain, the set of armed
1033edges with their AWS regions and health, and the bucket records. Over that
1034model, these must hold after every converge pass, armed or not:
1035
1036- **I1, continuity.** Arming, and every later pass, leaves the answer
1037 unchanged for every name that has no steered record set of its own. This
1038 includes names that sit beneath an empty non-terminal a steered name
1039 creates, nested canaries, names whose steering was turned off, and names
1040 whose record was deleted. "The answer" is the result of RFC 4592
1041 resolution over the whole zone, not an exact-name lookup.
1042- **I2, validity.** For every steered name, the zone holds at most one latency
1043 record per AWS region. The record set is a function of the set of eligible
1044 edges serving that name, **grouped by AWS region**. Adding, removing or
1045 renaming a box changes the answers inside a region's group. It never
1046 changes how many latency records the name has for that region. One way to
1047 get this is one latency record per region, aliasing a per-region,
1048 health-checked set of that region's edges. The spec chooses.
1049
1050 This groups by region rather than "keyed by edge" because Route53's rule is
1051 per region.
1052- **I3, serviceability.** Every answer for a steered name is an edge that can
1053 serve it: healthy, holding the account's certificate, and able to reach the
1054 name's home through the home map. The far edge backhauls; it never
1055 answers 404.
1056- **I4, withdrawal.** A withdrawal justified by a complete read (a record
1057 deleted, steering turned off, a box retired) converges within a bounded
1058 number of passes. If it cannot, it raises a named operator alert with a
1059 documented procedure. The short-read guard still refuses deletions that a
1060 complete read does not justify, but a refusal of deletions never blocks
1061 unrelated additions in the same pass.
1062- **I5, dormancy.** With no zone configured, nothing is read and nothing is
1063 written. This is already true (`steering_loop.go:219`).
1064- **I6, authority.** The converger only writes to the zone the apps domain is
1065 actually delegated to. It verifies the delegation before its first write,
1066 and stays dormant, loudly, when it cannot.
1067
1068**The proof the follow-on spec must ship:** a property test over generated
1069fleets. It covers random edges, random AWS regions including duplicates,
1070random steered and unsteered names including nested and sibling names, and
1071random sequences of creates, deletes, opt-outs, moves and box retirements.
1072
1073Each scenario runs through `desiredState` and `converge` against a Route53
1074fake. The fake enforces the per-region latency rule, batch atomicity, and an
1075RFC 4592 resolver. The test asserts I1 to I5 after every step, at high seed
1076counts, and each invariant's assertion is checked by mutation: break the
1077code, and watch the test fail. Examples alone have now missed three faults
1078in a row.
1079
1080### 13.3 Gates
1081
1082- **Do not arm #169** (`CARLOS_STEERING_ZONE_ID`) on any deployment until the
1083 follow-on spec has landed and its simulation passes. This holds for the
1084 static-site steering that already exists, independently of this spec.
1085- The follow-on spec carries the infrastructure plan for scoped approval
1086 (AGENTS.md):
1087 - the zone and its delegation;
1088 - `steering_zone_ids` and `steering_record_names = ["*.<apps-domain>"]` on
1089 every platform box's `region-host` module
1090 (`infra/modules/region-host/main.tf:281`, `variables.tf:153`);
1091 - `CARLOS_STEERING_ZONE_ID` in each box's `host.env`;
1092 - cost, cleanup and disarm.
1093- It also carries the latency measurement. Rev 3's method stands and moves
1094 there: `dig +subnet` against the zone's nameservers, then
1095 `curl -w '%{time_connect} %{time_appconnect} %{time_starttransfer}'` from a
1096 vantage in each region, 30 runs per host, with medians and p90. Take a
1097 baseline after this spec's placement ships and before arming, measure again
1098 after, and record the numbers in #169.
1099
1100Until then, a deployment that runs this spec is **placement-only**. A UK
1101instance is reached through the deployment's static DNS (on the flagship, the
1102Ireland EIP) and backhauled to the UK box. That costs the visitor about one
1103Ireland–London round trip compared with landing in London. It is no worse
1104than today for anyone, and it relieves Ireland's disk for every new non-home
1105instance.
1106
1107## 14. Place names
1108
1109**(decision) One table, `internal/geo/awsregions.go`, maps an AWS region code
1110to `{latitude, longitude, place}`.**
1111
1112- It covers the AWS commercial regions on AWS's official list as of
1113 2026-10-01 (C23), and records that source and date in a comment.
1114- Coordinates are the metro area's, each checked by hand when the table is
1115 built.
1116- A region missing from the table shows its bare code and is left out of the
1117 guess, with a log line.
1118- The table is used everywhere: the picker, the Instances tab, every `name`
1119 in §9, and the CLI lines.
1120
1121**`STATUS_BOX_REGION` stays, with a different job.** It is the public status
1122page's commitment: operator free text, deliberately coarse, and "never derived
1123from cloud metadata" (`agent.go:88-92`, README `:578`). It may stay coarser
1124than the picker, but it must agree geographically, and the README says so.
1125
1126## 15. Failure modes
1127
1128| what fails | what happens |
1129| --- | --- |
1130| `CONSOLE_GEOIP_DB` unset | IP step off: timezone, then home |
1131| mmdb missing, unreadable or corrupt at startup | Logged once; IP step off; the console starts. |
1132| mmdb lookup error | A miss; rate-limited log. |
1133| timezone UTC, `Etc/*`, unknown or malformed | IP step, then home. |
1134| 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`. |
1135| a region's boxes hold no secrets key, or a different one (R9) | Not eligible for any automatic choice. App-derived defaults are refused with C16 `no-secrets-key`. Explicit socket-backed or replica creates are refused with C31 once a current writer proves the mismatch. |
1136| console has no `CARLOS_SECRETS_PUBKEY` (R9) | Logged once. No coded region is eligible for automatic placement. Explicit creates keep today's behaviour. |
1137| edges listing fails | The guess is the home. App-derived creates get 503 C28. Explicit creates are unchanged. |
1138| last box of the app's region retired | App-derived creates are refused (C16 `absent`), and `app_default` and C8 say why. |
1139| home publishes no AWS region | Every guess is `default`. Fix it with the box's `CARLOS_AWS_REGION`. |
1140| an eligible region's AWS region is not in the table | Left out of the guess, still selectable, shown by code. |
1141| app's region excluded by a later allow-list | Refused (C17). |
1142| app-region read fails at the door | 502 (C20); no record written. |
1143| manifest unreadable | The guess endpoint returns 502 (C27). `/regions` answers `policy-unreadable`. |
1144| switch on, home unnamed | The switch is treated as off; logged once. |
1145| console binary rolled back below the floor | R7: no-region creates land at the home; roll-forward lists possible discrepancies. |
1146| steering unarmed | Placement-only, with no error. A non-home instance is reached through the static DNS and backhauled (§13.3). |
1147| a manifest's allow-list excludes the home, and no hint maps | `"guess": null`, never the home (§6.4). |
1148| owner adopts an existing app | `""`/`default`, unless `region` is explicit. |
1149
1150## 16. Test plan
1151
1152**`internal/geo`.**
1153
1154- Haversine on known pairs: Dublin to London is about 464 km, within 5.
1155- Nearest:
1156 - a single candidate, and a clear winner;
1157 - ties go to the home, then the smaller code;
1158 - two codes in one AWS region;
1159 - an ineligible candidate nearer than every eligible one is never picked;
1160 - a candidate with no point is excluded, a home with no point gives
1161 `default`, and no candidates gives `default`.
1162- Timezones:
1163 - `Europe/London` and `Europe/Amsterdam` map;
1164 - `Asia/Calcutta` and `Asia/Kolkata` give one point;
1165 - `America/Argentina/Buenos_Aires` maps, and matching is case-insensitive;
1166 - `UTC`, `Etc/UTC`, `Etc/GMT+5`, `GMT`, `Factory`, `Local`, `""`, a
1167 65-byte name, `../etc/passwd` and a link loop are all unmappable.
1168- Country points: every multi-row country has an override.
1169- mmdb, against pinned fixtures:
1170 - unset, missing, truncated and non-mmdb files;
1171 - City and Country-only records;
1172 - private, loopback and CGNAT addresses never reach the reader.
1173
1174**Secrets key (R9):**
1175
1176- `appconfig.KeyFingerprint` of a key's public half equals the fingerprint
1177 computed from the base64 `CARLOS_SECRETS_PUBKEY` of the same key.
1178- A box with the key publishes `secrets_key_fp`. With no key, or a key that
1179 does not parse, it omits the field. The body never contains the private key
1180 or the public key itself.
1181- Eligibility:
1182 - a box with a matching fingerprint is eligible;
1183 - a box with a different fingerprint, or none, is `no-secrets-key`;
1184 - a console with no `CARLOS_SECRETS_PUBKEY` makes no coded region
1185 eligible.
1186- An explicit socket-backed create into a region where only current writers
1187 publish a mismatch is refused with C31. Under an old writer, or with no
1188 console key, it is accepted as today. Static and addr-backed creates are
1189 never refused for it.
1190
1191**Edges file and eligibility** (codex R2-1):
1192
1193- The writer publishes `serves_shared_pool` and `accepts_inbound` from
1194 `CARLOS_POOLS` and `CARLOS_INBOUND`, for:
1195 - unset;
1196 - `CARLOS_POOLS=tito`;
1197 - `CARLOS_POOLS=,tito`, which the parser reads as `["tito"]`, so it
1198 publishes `serves_shared_pool: false` (codex R3-3);
1199 - `CARLOS_INBOUND=none`.
1200- The published fields are asserted equal to `poolServed` and
1201 `cfg.Inbound != InboundNone` over the same `provisionTickConfig` output, for
1202 a table of `CARLOS_POOLS` and `CARLOS_INBOUND` spellings. That makes
1203 publication and reconciliation one predicate.
1204- The reader treats absent as unknown and false as no.
1205- Eligibility needs all three fields true on one reachable box.
1206- The guess against a named-pool-only box returns no guess for that code.
1207- The guess against a no-inbound box does the same.
1208- In both cases, the matching reconcile pass on that box refuses the record
1209 (`reconcile.go:925`, `:1339`). That pins why the guess must not go there.
1210
1211**Store.**
1212
1213- Four database states: fresh; from before the columns; from before
1214 `migrateAppsKey` but carrying the new ALTERs; and `migrate` run twice.
1215- Pre-existing rows read `""`/`""`.
1216- `CreateAppInRegion` writes both columns in one statement.
1217- Rename and transfer keep the region.
1218
1219**The door** (`apiinstances_test.go`):
1220
1221- No region, with app region `uk` eligible: the record says `uk` and
1222 `region_from` says `app`.
1223- App region `""`: the record is byte-identical to the pre-change golden.
1224- Explicit wins over the app, and fleet wins over both.
1225- An explicit home name gives `""`.
1226- Pooled, static, addr-backed and replica creates are not filled, and the
1227 replica refusal is word for word.
1228- An unavailable app region gives C16, the allow-list C17, a store error 502,
1229 and a defaulted home inside its `replicas` C26.
1230- **Dormancy versus app-derived defaults (codex R2-2):**
1231 - With no placement published, an explicit `uk` is accepted, as today.
1232 - An app-derived `uk` is refused with C16 `absent`.
1233 - The last UK box's file removed: an app-derived `uk` is refused.
1234 - The edges listing fails: an app-derived create gets 503 C28, and an
1235 explicit create is unchanged.
1236- The switch off with an existing `uk` row: the fill still applies.
1237
1238**No stamp** (§13.1). A non-home instance created by this spec carries no
1239`Steering` field. The record is byte-identical to one created by
1240`instances create --region` before this spec.
1241
1242**The guess invariant** (codex R3-4). A property test over generated
1243policies, candidate sets, missing coordinates, unmappable hints and
1244edges-read failures asserts two things:
1245
1246- every non-null guess passes `instanceRegionAllowed(m.Regions,
1247 homeRegionIn(code))`;
1248- a non-null guess exists whenever some filtered candidate is reachable by
1249 the order in §6.4.
1250
1251Named cases:
1252
1253- an allow-list of only `uk`, with the home's coordinates missing;
1254- an unmappable hint against the same list;
1255- an edges-read failure against the same list, which gives `null`.
1256
1257**Pools, end to end.** A pooled manifest gives `guess: null` and
1258`app_default.reason: pooled`.
1259
1260**Fleets, end to end** (codex R4-1). For an app placed on `fleet/acme` with
1261an unrestricted shared-pool manifest:
1262
1263- `/regions/guess` returns `null`;
1264- a create that submits `uk` anyway produces a record whose region is
1265 `fleet/acme`, which pins why the guess is withheld;
1266- a failed placement read gives 502 C27.
1267
1268**Service through the real path** (codex R4-3), as an integration test with
1269two edges and a backhaul between them:
1270
1271- a non-home instance answers a request that enters through the other edge;
1272- with no mesh key mirrored, the same request gets the unknown-host answer
1273 (`internal/edge/edge.go:2699`, `:1428`), and the Phase P gate (§17) treats
1274 that as a failure, not a pass.
1275
1276**App create.**
1277
1278- An old body from a person resolves by IP, then home. This needs `ip` passed
1279 through `regionReq` (codex R2-5).
1280- A `timezone` body.
1281- Explicit regions: valid, invalid (C18) and unarmed (C19).
1282- `place` plus `region`.
1283- The switch off, or on with an unnamed home.
1284- A service principal gets no IP step.
1285- Adoption:
1286 - with `@auto`, or a `timezone` but no `region`, it stores `""`;
1287 - with an explicit code, it stores the code;
1288 - **a region named `auto`, and a home named `auto`, both work and are
1289 distinguishable from `@auto`** (codex R2-3).
1290
1291**Browser.**
1292
1293- One region, or the switch off: the form is byte for byte today's.
1294- `@auto` is labelled with the IP guess.
1295- §9.5 needs a session and CSRF.
1296- An explicit code records `chosen`.
1297
1298**Endpoints.**
1299
1300- `/regions` for an existing app matches today's golden in `regions` and
1301 `default`.
1302- `app_default` covers each `reason`.
1303- For an app storing `uk` and placed on `fleet/acme`: `region` is `uk`,
1304 `effective` is `fleet/acme`, and `deploy --region uk` passes while
1305 `deploy --region ie` is refused (codex R2-4).
1306
1307**Rollback list** (codex R2-6). A non-home app is not listed for any of:
1308
1309- its static apex;
1310- an addr-backed record;
1311- a replica;
1312- a pooled record.
1313
1314A no-region instance record created after the app is listed as a possible
1315discrepancy, worded as such.
1316
1317**Privacy.** With a canonical-host redirect configured, a guess POST's `tz`
1318and `ip` appear in none of:
1319
1320- the `Location` header;
1321- the redirect body;
1322- any error body;
1323- the captured logs;
1324- any audit row.
1325
1326**CLI.**
1327
1328- Timezone detection with injected functions.
1329- One output line per source.
1330- C13a and C13b.
1331- `deploy --region`: different is refused before anything ships, and equal
1332 is silent.
1333- `canary --region` against an existing canary in another region is refused
1334 before any write or upload.
1335- `canary --region uk` on a fleet-placed app, run twice: both runs refuse
1336 with C30 before any create, rechannel or upload. The first run creates
1337 nothing, so the second sees the same state (codex R4-2).
1338- Tests use `carlosTestEnv` with a scratch `cmd.Dir`.
1339- `20-cross` covers the CLI changes.
1340
1341## 17. Rollout
1342
1343**Phase P: placement.**
1344
13451. **Before any roll.**
1346 - Audit production manifests for `regions` (§12).
1347 - Confirm each box publishes its AWS region. In particular, confirm the
1348 flagship's Ireland box publishes `region: eu-west-1` and no `placement`.
1349 Use `CARLOS_AWS_REGION` where it does not.
1350 - Set `CONSOLE_HOME_REGION` on every console that will turn the switch on.
13512. **Stage the secrets key (R9).** Every box in a region that should take
1352 placed instances gets the deployment's `CARLOS_SECRETS_KEY` in
1353 `/etc/carlos/host.env`, from the deployment's secret store. On the flagship
1354 today that means the UK, US and AU boxes. This is infrastructure work in
1355 carloku-infrastructure, under the scoped-approval rule (AGENTS.md). Set
1356 `CARLOS_SECRETS_PUBKEY` on the console if it is not already set.
13573. **Box release.** It carries the edges-file eligibility fields and
1358 `secrets_key_fp`. Roll it to every box. Then check that every region's
1359 files carry all of them, and that each `secrets_key_fp` equals the
1360 console's. Until then, no coded region is eligible (§6.1).
13614. **Console release, switch off.** It carries the migration, `internal/geo`,
1362 the fill, the endpoints and the UI. Behaviour is unchanged, and it can be
1363 rolled back freely.
13645. **Verify on dev.** Turn the switch on with two eligible regions. Then:
1365 - create an app in the browser, and read its row;
1366 - create an instance with no region, and read its record;
1367 - diff `/regions` for an old app against the pre-roll answer;
1368 - run the service gate below.
13696. **Service gate on the flagship, with the switch still off** (codex R4-3).
1370
1371 Eligibility proves configuration, not a working request path.
1372 Reachability is only a non-empty EIP (`instancesplacement.go:185`), and
1373 backhaul refuses to dial without a mirrored mesh key (`edge.go:2699`).
1374 So before the switch goes on, **every eligible region must serve a fresh
1375 instance through the real public path.**
1376
1377 For each eligible region R, create one probe instance of a test app with
1378 an explicit `--region R`. That is ordinary API use and needs the switch
1379 off. Deploy a known build to it, then check:
1380
1381 - **From outside, through the platform host:** `curl -s -D - -o /dev/null
1382 https://<probe host>/` from a machine outside the deployment, using
1383 public DNS as it stands. On the flagship that enters through the Ireland
1384 edge, so every non-home region is exercised over backhaul. Expect 2xx,
1385 and an `X-Carlos-Version` equal to the deployed build.
1386 - **Through an edge that is not the instance's box:** for every armed
1387 edge E other than R's box, `curl -s -D - -o /dev/null --resolve <probe
1388 host>:443:<E's EIP> https://<probe host>/`, with the same expectations.
1389 This covers the home region too, entered from a far edge, and every
1390 edge-to-box pair the static DNS could someday route.
1391
1392 A 404 with the unknown-host answer, a TLS failure or a version mismatch
1393 fails the gate. **The switch is turned on only when every eligible region
1394 passes.** A region that fails is fixed first (mesh key, certificate,
1395 activator), or its boxes are made ineligible, before the switch goes on.
1396 The probe instances are deleted afterwards. The same gate is re-run before
1397 the switch is turned on again after any period off, and whenever a new
1398 region becomes eligible.
13997. **Flagship.** Roll the console, then turn the switch on. **From here the
1400 binary rollback floor applies (R7).** That release's notes and the roll
1401 recipe in carloku-infrastructure name the floor version. Optionally, set
1402 `CONSOLE_GEOIP_DB` to a DB-IP Lite file.
14038. **CLI release.** It is safe in either order (§9.6).
14049. **titogo**, in its own repo, adopts `/regions/guess` and the place names.
1405
1406No step of Phase P depends on Phase L. Phase P writes nothing to DNS, stamps
1407no `Steering` field, and needs neither `CARLOS_STEERING_ZONE_ID` nor any
1408change to the zone.
1409
1410**Phase L: latency (R6, R8)** is the follow-on spec's rollout, not this one's
1411(§13.3). Its gate is that spec landing with its simulation passing, plus
1412scoped approval for the infrastructure. Until then, **no deployment arms
1413`CARLOS_STEERING_ZONE_ID`**, including for the static-site steering that
1414already exists. The latency baseline is taken once Phase P step 7 has run.
1415That makes Phase L depend on Phase P, never the other way round.
1416
1417Copy review (§19) happens before Phase P step 4 ships any string.
1418
1419## 18. Questions for Paul
1420
1421None. R8 settled rev 4's sequencing question.
1422
1423## 19. Copy (pending copy review)
1424
1425These are drafts, and none ships until Paul has reviewed it through copy
1426review. There are no em dashes. `{…}` is filled at runtime.
1427
1428| id | where | draft |
1429| --- | --- | --- |
1430| C1 | new-app page, picker label | Region |
1431| 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. |
1432| C3 | new-app page, `@auto` option | {place} (nearest to you) |
1433| C5 | new-app page, flash | That region can't take new apps right now. Choose another. |
1434| C7 | Instances tab | New instances start in {place}, this app's region. |
1435| 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. |
1436| C9 | CLI, from timezone | Region: {place} (from your timezone). Use --region to choose another. |
1437| C10 | CLI, from IP | Region: {place} (from your network location). Use --region to choose another. |
1438| C11 | CLI, fallback | Region: {place} (the platform default). Use --region to choose another. |
1439| C12 | CLI, chosen | Region: {place}. |
1440| C13a | CLI, old console ignored --region | This console does not record an app region yet, so --region was not applied. |
1441| 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}. |
1442| C14 | CLI, instances create and canary | created instance {host} (channel {channel}, {place}) |
1443| 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. |
1444| 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}. |
1445| 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. |
1446| C18 | API, app create, unknown region | region {code} is not one this platform runs in. Choose from: {list}. |
1447| C19 | API, app create, region not armed | region {code} can't take new apps right now. Choose from: {list}. |
1448| C20 | API, app region unreadable | could not read this app's region. Try again. |
1449| C21 | activity feed | created app {name} in {place} |
1450| 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)". |
1451| 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.) |
1452| C30 | CLI, canary --region on a fleet-placed app | {app} runs on your fleet {fleet}, which decides where every instance runs, so --region does not apply. Run the command without --region. |
1453| C31 | API, explicit create into a region without the secrets key (R9) | region {code} can't run instances right now: its boxes don't hold this deployment's secrets key, so an instance there would start without its config or secrets. Choose another region. |
1454| 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. |
1455| 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. |
1456| C27 | API, guess with unreadable policy | could not read this app's instance policy, so there is no region suggestion. Try again. |
1457| C28 | API, app region unconfirmed | could not confirm that this app's region {code} can take new instances. Try again, or pass a region. |
1458| 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. |
1459
1460The `{reason}` words in C16 are `absent`, `not-hosting`, `no-secrets-key`
1461(R9) and `not-eligible`. Shown to a person, they read: "no box runs there
1462now", "boxes there can't start instances", "boxes there can't open this
1463deployment's secrets" (R9), and "boxes there don't take ordinary instances".
1464Those are copy too.
1465
1466C23, place names:
1467
1468| AWS region | place | AWS region | place |
1469| --- | --- | --- | --- |
1470| us-east-1 | US East · N. Virginia | eu-north-1 | Sweden · Stockholm |
1471| us-east-2 | US East · Ohio | il-central-1 | Israel · Tel Aviv |
1472| us-west-1 | US West · N. California | me-south-1 | Bahrain |
1473| us-west-2 | US West · Oregon | me-central-1 | UAE |
1474| ca-central-1 | Canada · Montréal | af-south-1 | South Africa · Cape Town |
1475| ca-west-1 | Canada · Calgary | ap-south-1 | India · Mumbai |
1476| mx-central-1 | Mexico · Querétaro | ap-south-2 | India · Hyderabad |
1477| sa-east-1 | Brazil · São Paulo | ap-east-1 | Hong Kong |
1478| eu-west-1 | Ireland · Dublin | ap-east-2 | Taiwan · Taipei |
1479| eu-west-2 | UK · London | ap-southeast-1 | Singapore |
1480| eu-west-3 | France · Paris | ap-southeast-2 | Australia · Sydney |
1481| eu-central-1 | Germany · Frankfurt | ap-southeast-3 | Indonesia · Jakarta |
1482| eu-central-2 | Switzerland · Zurich | ap-southeast-4 | Australia · Melbourne |
1483| eu-south-1 | Italy · Milan | ap-southeast-5 | Malaysia |
1484| eu-south-2 | Spain · Aragón | ap-southeast-6 | New Zealand · Auckland |
1485| ap-northeast-1 | Japan · Tokyo | ap-southeast-7 | Thailand · Bangkok |
1486| ap-northeast-2 | South Korea · Seoul | ap-northeast-3 | Japan · Osaka |
1487
1488The `docs/site/cli.md`, README and release-note additions (§5.7, §11, §12,
1489§14) are prose, and go through the same review.
1490
1491## 20. Follow-ups (not in this spec)
1492
1493- **Changing an existing app's region** (R5). An owner-only verb that affects
1494 future creates only and leaves existing instances alone.
1495- **Phase L: latency routing (R6)**, on its own spec, meeting §13.2's
1496 invariants with a simulation over generated fleets. It derives "steer this
1497 host" from the record's region, which also covers moved instances. It
1498 carries #169's infrastructure plan, the delegation check for the flagship's
1499 `oncarlos.com` zone, and the latency measurement.
1500- **Pool-aware candidates.** Pool eligibility proven by the target box itself.
1501- **Sidecars following the app region**, with an armed gate on their door.
1502- **A browser instance-create form.**
1503- **Tightening #162** for explicit home-in-own-replica-set creates.
1504
1505## 21. Review dispositions
1506
1507### Round 1 (rev 1, 7fbc9479)
1508
1509| # | sev | finding | disposition |
1510| --- | --- | --- | --- |
1511| 1 | P1 | A picker submitting the guess bypasses the pool exclusion | **accepted.** No guess for a pooled policy (§6.1). |
1512| 2 | P1 | Browser adoption silently changes an existing app's default | **accepted.** `regionReq` with guess resolution after adoption (§5.5). |
1513| 3 | P1 | An unnamed home makes override and recovery impossible | **accepted.** Non-home writes need a named home (§5.6). |
1514| 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. |
1515| 5 | P2 | `canary --region` undefined for an existing canary | **accepted** (§11). |
1516| 6 | P2 | Manifest absence and read failure unspecified | **accepted.** Three policy states (§6.1, §9.3). |
1517| 7 | P2 | Unavailable-default explanation disappears | **accepted.** `app_default` (§9.3), C8 (§10). |
1518| 8 | P2 | Query hints reflected by redirects | **accepted.** POST bodies only (§6.5, §9.4, §9.5). |
1519
1520### Round 2 (rev 2, bd4ed0c4)
1521
1522| # | sev | finding | disposition |
1523| --- | --- | --- | --- |
1524| 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. |
1525| 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). |
1526| 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). |
1527| 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). |
1528| 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). |
1529| 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). |
1530
1531### Astra's recommendations on rev 1's open questions
1532
1533| rev 1 Q | disposition |
1534| --- | --- |
1535| Q1 titogo adoption in "done" | superseded by R5: done is new apps plus moves |
1536| Q2 placement-only, measure first | superseded by R6: latency is in scope. Measurement moves to the follow-on spec (§13.3). |
1537| Q3 refuse an unavailable default | adopted (§7) |
1538| Q4 creation-only region | adopted; the change verb is a follow-up (R5, §20) |
1539| Q5 defer the browser instance form | adopted (§20) |
1540| Q6 DB-IP Lite, corrected GeoLite2 note | adopted (§6.2, C24) |
1541| Q7 pinned reader and fixtures | adopted (§6.2) |
1542| Q8 picker style, geographic consistency | adopted (§14) |
1543| Q9 verify home publication, `CARLOS_AWS_REGION` | adopted (§17) |
1544| Q10 sidecars unchanged | adopted (§20) |
1545| Q11 suppress pooled guesses | adopted (§6.1) |
1546| Q12 home inside own replica set | adopted for defaulted regions (§7); explicit is a follow-up (§20) |
1547| Q13 keep allow-list normalization, audit | adopted (§12, §17) |
1548| Q14 unnamed home | adopted, as finding 3 |
1549| Q15 Windows fallback | adopted (§11) |
1550| Q16 AWS list | adopted (§14) |
1551
1552### Astra's round-2 recommendations on rev 2's questions
1553
1554| rev 2 Q | disposition |
1555| --- | --- |
1556| Q1 (b): titogo in "done" | superseded by R5 |
1557| Q2 (a): placement-only, measure first | superseded by R6. Measurement moves with Phase L to the follow-on spec (§13.3). |
1558| Q3 (a): accept the floor | matches R7 |
1559
1560### Round 3 (rev 3, 8e59f598)
1561
1562| # | sev | finding | disposition |
1563| --- | --- | --- | --- |
1564| R3-1 | P1 | Arming per-host steering breaks DNS for unsteered siblings, through empty non-terminals shadowing `*.<apps-domain>` | **accepted.** Verified against `steering.go:369`, `:386` and carloku's single wildcard (`dns53.tf`, `dns.tf`). It already affects static-site steering. Resolved by invariant I1, the gate on arming #169, and the move of Phase L to a follow-on spec (§13). |
1565| R3-2 | P1 | One latency record per edge is invalid when two edges share an AWS region | **accepted.** Verified at `route53.go:597`. Resolved by invariant I2, which groups by AWS region, and a Route53 fake that enforces the rule (§13.2). This departs from the coordinator's example wording "keyed by edge, not region", because Route53's constraint is per region. |
1566| R3-3 | P1 | The `CARLOS_POOLS=,tito` test would publish shared eligibility the reconciler denies | **accepted.** Verified at `agent.go:2033`. Publication is computed from the same `ReconcileConfig` and predicate, `,tito` publishes false, and a test pins the equality (§6.1, §16). |
1567| R3-4 | P2 | Home fallback is not constrained by the allow-list | **accepted.** The guess invariant, with the home fallback only when the home passes the policy, and a property test (§6.4, §16). |
1568| R3-5 | P2 | Large DNS withdrawals are refused indefinitely, not spread over passes | **accepted.** Verified at `route53.go:207` and `steering_loop.go:154`. The wrong claim is removed, and invariant I4 makes it the follow-on spec's requirement (§13). |
1569| R3-6 | P3 | §4 reverses which requests need positive eligibility | **accepted.** Corrected to app-derived (§4). |
1570
1571### Astra's round-3 recommendations
1572
1573| former question | disposition |
1574| --- | --- |
1575| Q1: apply R5, titogo separate | adopted (§2, §17 step 9) |
1576| Q2: implement R6 with the DNS corrections, keep the separate approved Phase L rollout and measurements | adopted in substance. The corrections are larger than a patch, so they move to the follow-on spec with invariants (§13); the sequencing is ruled by R8. |
1577| Q3: name the exact floor version before activation; keep region-aware reads when disabling | adopted (§5.6, §5.7, §17 step 7) |
1578
1579### Round 4 (rev 4.1, 4ad64f7c)
1580
1581| # | sev | finding | disposition |
1582| --- | --- | --- | --- |
1583| R4-1 | P2 | Fleet-placed apps still get instance guesses the door will override | **accepted.** Verified at `fleets.go:479` and `apiinstances.go:798`. The guess endpoint resolves fleet placement first and returns `null`; app-creation inference is unchanged; end-to-end test (§6.1, §9.4, §12, §16). |
1584| R4-2 | P2 | `canary --region` on a fleet behaves differently before and after creation | **accepted.** Verified at `canary.go:405` and `apiinstances.go:798`. Fleet precedence is resolved before either branch, and `--region` on a fleet-placed app refuses identically every time with C30, never suggesting a remove (§11, §12, §16). |
1585| R4-3 | P2 | Phase P verification can pass while non-home instances are unreachable | **accepted.** Verified at `instancesplacement.go:185`, `edge.go:2699` and `:1428`. A service gate before the production switch: a fresh probe per eligible region, reached from outside through the platform host and through every other edge with `--resolve`, checking 2xx and `X-Carlos-Version`. All regions must pass. Plus an integration test for the missing-mesh-key case (§16, §17 step 6). |
1586
1587Astra raised no open questions in round 4. §18 stands at none.
1588
1589### Amendment rev 6.2 (`instance_domain`, Paul 2026-10-03)
1590
1591Not a review finding of this spec. Astra's plan round 3 (finding 1) showed
1592that `canary --region`, deciding from the capped instance list, could miss
1593an existing canary and write. The plan answered with `instance_domain` and a
1594single-record read. Astra's plan round 4 judged it sound, including the
1595check of the PUT's answer against the preflight, and Paul approved it. §9.3
1596carries it, with the round-4 consequence of R9a for pooled and fleet apps.
1597
1598### Amendment R9a (rev 6.1, Paul 2026-10-02)
1599
1600Not a review finding of this spec. Astra's review of the implementation
1601plan (plan round 2, finding 3) showed that a 200 marked `policy-unreadable`
1602let the CLI read an unread region as the home. Paul accepted the two flags
1603that fix it. Astra's plan round 3 judged them sound. §3 (R9a) and §9.3 carry
1604them, and the copy record gains C15c for deploy's refusal.
1605
1606### Amendment R9 (rev 6, Paul 2026-10-02)
1607
1608Not a review finding: a production finding, ruled by Paul. Recorded here so
1609the dispositions list every change since rev 5. The changes are §3 (R9),
1610§6.1 (`secrets_key_fp`, its format, the console's expected fingerprint, the
1611fourth eligibility fact), §7 and §8.1 (the explicit refusal, C31), §9.3 and
1612C16 (the `no-secrets-key` reason), §15, §16, §17 (staging the key), and §19
1613(C31).
1614