carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

File too large to show in full — download it above (showing a truncated preview below).

1# Nearest Region by Default: Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Revision:** 7. A one-page summary for review (what ships per release,
6the task list, the copy-review gate, the rollout and the risks) is in
7`2026-10-02-nearest-region-default-SUMMARY.md`, beside this file.
8
9- Rev 1 (9513d52c) was reviewed by Astra
10 (`CODEX-PLAN-NEAREST-REGION-R1.md`, beside this file): not ready, with
11 8 P1 and 10 P2. Every finding was checked against the code and accepted.
12- Rev 2 (fcf15ae9) folded in spec rev 6's ruling R9: placed instances need
13 the deployment's secrets key.
14- Rev 3 answers Astra's round 2 (`CODEX-PLAN-NEAREST-REGION-R2.md`).
15- Rev 4 answers Astra's round 3 (`CODEX-PLAN-NEAREST-REGION-R3.md`) and
16 carries spec rev 6.1's R9a note (`region_known`, `placement_known`).
17- Rev 5 answers Astra's round 4 (`CODEX-PLAN-NEAREST-REGION-R4.md`) and
18 carries spec rev 6.2 (`instance_domain`, approved by Paul 2026-10-03).
19- Rev 6 answers Astra's round 5 (`CODEX-PLAN-NEAREST-REGION-R5.md`), and
20 marks the flagship's secrets-key staging done (Rollout step 2).
21- Rev 7 answers Astra's round 6 (`CODEX-PLAN-NEAREST-REGION-R6.md`).
22- The code blocks the findings touched were **proved**: extracted verbatim
23 into a throwaway worktree at the branch base, built and vetted, and each
24 prescribed test was run before and after its implementation step.
25- The six dispositions tables, with the proof results, are at the end.
26
27**Goal:** Give every new app a region at creation, preselected from a timezone-then-IP guess, and have new instances default to that region. Never place an instance automatically on a box that cannot open the deployment's secrets. Existing apps, older CLIs and DNS stay unchanged.
28
29**Architecture:**
30
31- **Boxes** publish four more facts in their edges file: `serves_shared_pool`,
32 `accepts_inbound`, and `secrets_key_fp` (the fingerprint of their secrets
33 key's public half). Together these let the console tell which regions can
34 really run an ordinary instance.
35- **A new package, `internal/geo`,** turns timezones and IPs into points and
36 picks the great-circle nearest eligible region.
37- **The console:**
38 - stores `region` and `region_source` on the `apps` row, resolved inside
39 `claimApp` after it knows whether the claim is an adoption;
40 - fills an empty instance region from the app's region before the
41 existing checks;
42 - refuses explicit creates into a region provably without the secrets key
43 (R9);
44 - adds `app_default` to `GET …/regions`, and a POST guess endpoint.
45- **The CLI** sends a timezone hint and gains `--region` where the spec says.
46
47All non-home writes sit behind `CONSOLE_APP_REGIONS=on` together with a named
48home.
49
50**Tech Stack:**
51
52- Go 1.26 and SQLite (modernc).
53- An embedded IANA tzdata `zone.tab` and its links.
54- `github.com/oschwald/maxminddb-golang` v1.13.1 for the optional
55 `CONSOLE_GEOIP_DB`.
56- `crypto/ecdh` P-256, as already used by `internal/appconfig`.
57- `net/http` and `html/template`.
58
59**Spec:** `docs/superpowers/specs/2026-10-01-nearest-region-default-design.md`
60(rev 6.2, approved by Paul 2026-10-02, including the R9 amendment, the R9a
61note and the rev 6.2 `instance_domain` note approved 2026-10-03). Read it
62beside this plan. Section numbers below (§N) are the spec's.
63
64## Global Constraints
65
66- **Scope is placement only (R8).** No DNS writes, no `Steering` stamp, no
67 converger change, no `CARLOS_STEERING_ZONE_ID`.
68- **Nothing writes a non-home app region** unless `CONSOLE_APP_REGIONS=on`
69 **and** `CONSOLE_HOME_REGION` is set (§5.6). With the switch off, the door's
70 fill still runs for rows that already hold a region.
71- **`region` is stored in the record vocabulary, where `""` is the home**
72 (§5.1). `region_source` ∈ {`chosen`, `timezone`, `ip`, `default`, `""`}.
73- **Never stored, logged, audited, put in a URL, or echoed** in a response or
74 error: a timezone, a client IP, a coordinate, or any secrets key
75 (§6.5, R9). That includes errors returned by dependencies, which are
76 logged as fixed categories, never verbatim, wherever they could carry an
77 address.
78- **Every non-null guess passes**
79 `instanceRegionAllowed(m.Regions, homeRegionIn(code))` (§6.4).
80- **Automatic choices need positive eligibility on one box**: reachable;
81 `hibernation_configured`, `serves_shared_pool` and `accepts_inbound` all
82 `true`; and `secrets_key_fp` equal to the fingerprint of the console's
83 `CARLOS_SECRETS_PUBKEY` (§6.1, R9). The home is exempt.
84- **Explicit requests keep today's gates**, plus one refusal (R9, C31): a
85 socket-backed or replica create into a region where every reachable,
86 hosting box is a current writer and none holds the deployment's key.
87- **App-derived defaults never pass on the dormant gate.** An edges-read
88 failure is a 503 with C28 (§7).
89- **Pooled manifests and fleet-placed apps get no instance guess** (§6.1).
90- **Wire changes are additive** (AGENTS.md).
91- **Copy.**
92 - Every user-facing or operator-facing string is reviewed **before** it is
93 written into any product file. Task 0 runs the copy-review skill on the
94 complete inventory and records the approved text in
95 `docs/superpowers/plans/2026-10-02-nearest-region-copy.json`.
96 - Later tasks copy strings from that record verbatim.
97 - Task 25 proves every recorded string is present, byte for byte, in the
98 file the record names.
99 - No em dashes in any of it.
100- **Build and test gate**, clean before every commit that touches Go:
101 `go build ./...`, `go vet ./...`, `gofmt -l .` (empty), and
102 `go test ./... -count=1`. `make test` runs all of it.
103- **Cross-compile gate.** The five CI targets, exactly as
104 `.amadan/ci.d/20-cross@5` runs them:
105 `CGO_ENABLED=0 GOOS=<os> GOARCH=<arch> go build ./...` for darwin/arm64,
106 darwin/amd64, linux/arm64, windows/amd64 and windows/arm64. Every loop
107 below records each failure and ends with `[ "$fail" = 0 ] || exit 1`. Run
108 each such block as one script (`bash -c` or a tool call), so that a failed
109 target stops it before the commit lines that follow (codex plan-R2 10).
110- **Test environment.**
111 - Use the default Go build cache (`~/.cache/go-build`, writable inside the
112 sandbox). Do not set `GOCACHE`, `GOMODCACHE` or `GOTMPDIR`.
113 - Scratch goes under `$TMPDIR`, which is on disk.
114 - **Operator rule for this machine (socket tests).** The operator
115 instructs that ONLY the Unix-socket test packages run with `TMPDIR`
116 under `/tmp/g`: `internal/host`, `internal/activator`,
117 `internal/edge`, and any other package whose tests bind a Unix socket
118 (today also `internal/agentsock`, `internal/instancectl`,
119 `internal/instanceexec`, `internal/unixdial` and `internal/vet`). A
120 Unix socket path is capped at 108 bytes, and the on-disk `TMPDIR` here
121 (`/home/paulca/.cache/tmp/claude-1001/...`) is too deep for Go's test
122 directories plus the socket names under them. The directory is small
123 and is deleted after the run. `/tmp/g` is shared, so each run makes its
124 own directory under it and deletes only that; nothing else in `/tmp/g`
125 is touched. Everything else uses the on-disk `TMPDIR`. CI is
126 unaffected: it has no such path limit and sets nothing here. Every
127 socket-test command in this plan goes through this function:
128
129 ```bash
130 socktest() { # go test args...; TMPDIR is a fresh short dir, deleted after
131 mkdir -p /tmp/g || return 1
132 local d rc
133 d="$(mktemp -d /tmp/g/nr.XXXXXX)" || return 1
134 TMPDIR="$d" go test "$@"; rc=$?
135 rm -rf "$d"
136 return "$rc"
137 }
138 ```
139 - Console tests need an on-disk `TMPDIR` (the sandbox's `$TMPDIR` is one),
140 because an empty `/tmp/.git` breaks Go's VCS stamping.
141- **CLI tests that shell out to the carlos binary** build their env through
142 `carlosTestEnv` and set `cmd.Dir` to a scratch dir (AGENTS.md).
143- **Flags are documented.** `docs/site/cli.md` documents every new flag. No
144 new commands.
145- **Commits.** End every commit message with
146 `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. No push.
147 `git config` only with `--worktree`.
148
149## Review Focus
150
151The five inputs most likely to bite a person that no spec-derived test covers.
152Each is pinned by a test in the task named.
153
1541. **Real-machine timezone text.** An `/etc/timezone` with a trailing newline,
155 `TZ=":"`, `TZ="localtime"`, `TZ="posixrules"` and a POSIX rule string must
156 give a clean name or nothing. Pinned in Task 19 and Task 3.
1572. **IPv6 and IPv4-mapped clients.**
158 - `::ffff:81.2.69.142` locates as `81.2.69.142`.
159 - A public IPv6 address looked up in an IPv4-only database is a miss whose
160 log line never contains the address.
161 - Private and loopback addresses never reach the reader.
162
163 Pinned in Task 5.
1643. **A region that stops being eligible between rendering the new-app page and
165 submitting it** (for example, its box loses the secrets key). The form
166 re-renders with C5 and no app row is written. Pinned in Task 13.
1674. **Garbage hints at the guess endpoint.** `"ip":"not-an-ip"` and a 10 KB
168 `"tz"` are treated as absent: a 200, never a 400 or 500. Pinned in
169 Task 15.
1705. **A claim that fails after the region was resolved.** A lost insert race
171 writes no row and logs no `app region` line. A held apex refuses before
172 resolution and logs nothing either. Pinned in Task 12.
173
174---
175
176## File map
177
178| File | Responsibility | Tasks |
179| --- | --- | --- |
180| `docs/superpowers/plans/2026-10-02-nearest-region-copy.json` | the approved copy record | 0 |
181| `internal/provision/reconcile.go` | `ReconcileConfig.SharedInstanceEligibility()` | 1 |
182| `internal/host/edgesfile.go` | publish `serves_shared_pool`, `accepts_inbound`, `secrets_key_fp` | 1, 1b |
183| `internal/appconfig/fingerprint.go` | `KeyFingerprint`, the one renderer of `secrets_key_fp` | 1b |
184| `internal/geo/*` | points, nearest, tz data, AWS region table, GeoIP | 2 to 5 |
185| `scripts/update-tzgeo.sh` | refresh the embedded tz files | 3 |
186| `internal/console/regioncopy.go`, `regionmsgs.go` | console copy and API messages | 6, 7 |
187| `internal/console/store.go` | columns, rebuild list, accessors | 8 |
188| `internal/console/server.go` (Config), `regionconfig.go`, `cmd/console/main.go` | switch, GeoIP, expected secrets fingerprint, test seams | 9 |
189| `internal/console/regioncandidates.go` | strict edges read, candidates, eligibility incl. R9, policy | 10 |
190| `internal/console/regionguess.go` | `guessRegion` | 11 |
191| `internal/console/regionclaim.go`, `server.go`, `apicli.go` | `regionReq`, explicit validation, claim resolution, API create | 12 |
192| `internal/console/regionpicker.go`, `templates/app_new.html` | the new-app picker and its POST and guess route | 13 |
193| `internal/console/apiinstances.go` | the fill, app-derived eligibility, the R9 explicit refusal, response fields | 14 |
194| `internal/console/regiondefault.go`, `apiregions.go`, `authcli.go` | `app_default`, names, `POST …/regions/guess` | 15 |
195| `templates/app_instances.html`, `server.go` | Instances tab lines | 16 |
196| `internal/console/regiondrift.go` | roll-forward discrepancy log | 17 |
197| `region_copy.go`, `timezone.go`, `apps_region.go` (root) | CLI copy, timezone, region line | 18, 19, 20 |
198| `apimode.go`, `apps.go`, `deploy.go`, `canary.go`, `instances_cli.go` | CLI wire and flags | 20 to 22 |
199| `deploy_region_e2e_test.go`, `canary_region_e2e_test.go` (root) | command-level proofs | 21, 22 |
200| `scripts/region-service-gate.sh`, `internal/edge/servicegate_test.go` | the service gate and its real-backhaul proof | 23 |
201| `docs/site/cli.md`, `README.md` | docs, release checklist, floor | 24 |
202| `approved_copy_test.go` (root) | proves the record is applied | 25 |
203
204Order: Task 0 first, because no product file may hold an unreviewed string.
205Then 1, 1b, 2 to 25 in order.
206
207---
208
209### Task 0: Copy inventory, review and the approved record (before any product file)
210
211**Files:**
212- Create: `docs/superpowers/plans/2026-10-02-nearest-region-copy.json`
213
214**Interfaces:**
215- Produces:
216 - the approved copy record, read by every later task and asserted by
217 Task 25;
218 - its shape:
219
220```json
221{
222 "approved_by": "Paul",
223 "approved_on": "YYYY-MM-DD",
224 "entries": [
225 {"id": "C1", "file": "internal/console/regioncopy.go", "text": "Region"}
226 ]
227}
228```
229
230 `text` is the exact byte sequence the named file must contain. Go format
231 verbs (`%s`, `%v`, `%q`, `%d`) and shell variables (`$label`) stay as
232 written, because the file contains them.
233
234- [ ] **Step 0: Resolve the fixture commit (network, read-only)**
235
236F1 names the MaxMind-DB commit Task 5 fetches its fixtures from, so the
237commit is fixed BEFORE review and the reviewed text is the text Task 5
238writes (codex plan-R3 3). Nothing substitutes into an approved string later.
239
240```bash
241sha=$(git ls-remote https://github.com/maxmind/MaxMind-DB refs/heads/main | cut -f1)
242[ ${#sha} = 40 ] && [ -z "${sha//[0-9a-f]/}" ] || { echo "could not resolve MaxMind-DB main"; exit 1; }
243echo "$sha"
244```
245
246F1's draft, with that sha written in place of `<sha>` before the review:
247
248```
249The .mmdb files and GeoIP2-Country-Test.json are MaxMind's published test
250data from github.com/maxmind/MaxMind-DB, dual licensed MIT or Apache-2.0
251(LICENSE-MIT, LICENSE-APACHE beside this file). All were fetched at commit
252<sha>. They contain test records only, not real geolocation data.
253```
254
255- [ ] **Step 1: Assemble the complete inventory**
256
257Every string this plan writes into a product file, with its draft. The
258drafts are spec §19's where an id is a C-number. The rest are added by this
259plan and marked "plan".
260
261| id | file | draft |
262| --- | --- | --- |
263| C1 | `internal/console/regioncopy.go` | `Region` |
264| C2 | same | `Where this app's instances run. We picked the region nearest to you. Choose another if most of your users are somewhere else.` |
265| C3 | same | `%s (nearest to you)` |
266| C5 | same | `That region can't take new apps right now. Choose another.` |
267| C7 | same | `New instances start in %s, this app's region.` |
268| C8 | same | `New instances can't start in %s, this app's region, right now. Pass a region to place one elsewhere.` |
269| C21 | same | `created app %s in %s` |
270| C24 | same | `IP location by DB-IP.` (confirm DB-IP's required wording at <https://db-ip.com/db/lite.php>) |
271| R-absent | `internal/console/regionmsgs.go` | `no box runs there now` |
272| R-not-hosting | same | `boxes there can't start instances` |
273| R-no-secrets-key | same | `boxes there can't open this deployment's secrets` |
274| R-not-eligible | same | `boxes there don't take ordinary instances` |
275| C16 | same | `this app's region %s can't take new instances right now (%s). Pass a region to place this instance elsewhere. Regions that can: %s.` |
276| C17 | same | `this app's region %s is not one its instances may use. Allowed: %s. Pass a region to choose one.` |
277| C18 | same | `region %s is not one this platform runs in. Choose from: %s.` |
278| C19 | same | `region %s can't take new apps right now. Choose from: %s.` |
279| C16b (plan) | same | `this app's region %s can't take new instances right now (%s), and no other region this app may use can either. Try again later.` (C16 when no region would pass) |
280| C17b (plan) | same | `this app's region %s is not one its instances may use, and no region they may use can take new instances right now. Try again later.` (C17 when no allowed region would pass) |
281| C18b (plan) | same | `region %s is not one this platform runs in. Run the command without --region.` (C18 when no region would pass) |
282| C19b (plan) | same | `region %s can't take new apps right now. Run the command without --region.` (C19 when no region would pass) |
283| C20 | same | `could not read this app's region. Try again.` |
284| C26 | same | `this app's region %s is also one of this instance's replica placements. Pass a region for the instance, or drop %s from replicas.` |
285| C27 | same | `could not read this app's instance policy, so there is no region suggestion. Try again.` |
286| C28 | same | `could not confirm that this app's region %s can take new instances. Try again, or pass a region.` |
287| C29 | same | `possible placement discrepancy: %s of %s has no region, but %s's region is %s. It may be a deliberate home override; review it.` |
288| C31 | same | `region %s 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.` |
289| O1 (plan) | `internal/console/regionconfig.go` | `console: CONSOLE_APP_REGIONS=on but CONSOLE_HOME_REGION is unset, so app regions stay off until the home is named` |
290| O2 (plan) | same | `console: CARLOS_SECRETS_PUBKEY is unset, so no region outside the home can be chosen automatically` |
291| O3 (plan) | `cmd/console/main.go` | `console: CONSOLE_GEOIP_DB %s is unusable, so the IP step of region guesses is off: %v` |
292| O4 (plan) | same | `console: GeoIP database loaded from %s` |
293| O5 (plan) | `internal/geo/mmdb.go` | `geo: a GeoIP lookup failed and was treated as a miss` |
294| O6 (plan) | `internal/console/regioncandidates.go` | `console: boxes for region %q disagree on their AWS region %v; using %s` |
295| O7 (plan) | same | `console: region %q has no point in the shipped AWS region table (AWS region %q), so region guesses leave it out` |
296| O8 (plan) | `internal/console/server.go` | `console: app region %s/%s: %s (%s)` |
297| O9 (plan) | `internal/console/regiondrift.go` | `console: placement discrepancy check %s/%s: only the first %d records were checked` |
298| O10 (plan) | `internal/host/edgesfile.go` | `agent: CARLOS_SECRETS_KEY does not parse, so no secrets_key_fp is published: %v` |
299| O11 (plan) | `internal/console/apiinstances.go` | `console: api instances create %s/%s: read app region: %v` |
300| O12 (plan) | same | `console: api instances create %s/%s: read edges: %v` |
301| O13 (plan) | `internal/console/regioncandidates.go` | `console: region policy %s/%s: read manifest: %v` |
302| O14 (plan) | same | `console: region policy %s/%s: open registry: %v` |
303| O15 (plan) | same | `console: region policy %s/%s: placement: %v` |
304| O16 (plan) | `internal/console/apicli.go` | `console: claim %s/%s: read back region: %v` |
305| O17 (plan) | `apimode.go` | `regions: unreadable response: %w` |
306| O18 (plan) | `internal/console/regiondrift.go` | `console: placement discrepancy check %s/%s: %v` |
307| O19 (plan) | same | `console: placement discrepancy check: %v` |
308| O20 (plan) | `apimode.go` | `instance: unreadable response: %w` |
309| C9 | `region_copy.go` | `Region: %s (from your timezone). Use --region to choose another.` |
310| C10 | same | `Region: %s (from your network location). Use --region to choose another.` |
311| C11 | same | `Region: %s (the platform default). Use --region to choose another.` |
312| C12 | same | `Region: %s.` |
313| C13a | same | `This console does not record an app region yet, so --region was not applied.` |
314| C13b | same | `This console is not assigning app regions yet, so --region was not applied. This app's instances start in %s.` |
315| C14 | same | `channel %s, %s` |
316| C15 | same | `%s already runs in %s. --region only applies when deploy creates an app. To place one instance elsewhere, use carlos instances create --region.` |
317| C15b (plan) | same | `could not read %s's region (%v), so deploy stopped before shipping anything. Try again, or run without --region.` |
318| C15c (plan) | same | `the console could not read %s's region, so deploy stopped before shipping anything. Try again, or run without --region.` |
319| C25 | same | `canary %s already runs in %s. --region applies only when canary creates the host. Run carlos canary remove first to start it somewhere else.` **Note for Paul:** the verb is `carlos canary rm` (or `delete`); there is no `remove`. |
320| C30 | same | `%s runs on your fleet %s, which decides where every instance runs, so --region does not apply. Run the command without --region.` |
321| C30b (plan) | same | `this console can't say where %s's instances run, so --region can't be checked. Update the console, or run the command without --region.` |
322| C30c (plan) | same | `could not confirm where canary %s runs, so nothing was changed. Try again, or run the command without --region.` |
323| C22a | same | `region this app's instances start in (default: the region nearest you)` |
324| C22b | same | `region for the app this deploy creates (default: the region nearest you)` |
325| C22c | same | `region for the canary instance (default: the app's region)` |
326| C22d | same | `region for this instance (default: the app's region, subject to the manifest's allow-list)` |
327| U1 (plan) | `apps.go` | ` [--region <code>]` in the `apps create` usage string |
328| U2 (plan) | `deploy.go` | ` [--region <code>]` in the `deploy` usage string |
329| U3 (plan) | `canary.go` | `[--region <code>]` in `canaryUsage` |
330| S1 (plan) | `scripts/update-tzgeo.sh` | `usage: update-tzgeo.sh <tzdata version, e.g. 2026b>` |
331| S2 (plan) | same | `gpg not found: signature NOT checked` |
332| S3 (plan) | same | `internal/geo/data refreshed to tzdata $v` |
333| G1 (plan) | `scripts/region-service-gate.sh` | `usage: region-service-gate.sh <probe-host> <expected-version> [<edge-eip> ...]` |
334| G2 (plan) | same | `ok $label: $status $version` |
335| G3 (plan) | same | `FAIL $label: request failed` |
336| G4 (plan) | same | `FAIL $label: status $status` |
337| G5 (plan) | same | `FAIL $label: X-Carlos-Version '$version', want '$want'` |
338| G6 (plan) | same | `FAIL $label: connected to $ip, not $eip` |
339| C23 | `internal/geo/awsregions.go` | the 34 place names of spec §19, one entry each (`C23-us-east-1` and so on) |
340| D1 to D4 | `docs/site/cli.md` | the four paragraphs in Task 24 Step 1 |
341| D5 to D10 | `README.md` | the six passages in Task 24 Step 2 |
342| F1 (plan) | `internal/geo/testdata/README` | the fixture provenance note below, with `<sha>` already replaced by the commit Step 0 resolved |
343
344- [ ] **Step 2: Run the copy review**
345
346Invoke the `copy-review` skill with the whole inventory: every row, with
347where it appears and the C-number's spec context. Do not write any of these
348strings into a product file before the review returns. Wait for Paul's
349edits.
350
351- [ ] **Step 3: Write the approved record**
352
353Write the JSON file with one entry per row. Use Paul's approved text verbatim
354(the draft where he left it unchanged), in each string's code form. That
355means Go verbs in place of `{…}` placeholders, and the file the row names.
356Set `approved_on` to the review date.
357
358- [ ] **Step 4: Commit**
359
360```bash
361git add docs/superpowers/plans/2026-10-02-nearest-region-copy.json
362git commit -m "plan: approved copy record for nearest-region (copy review)
363
364Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
365```
366
367From here, every task that writes a string from the inventory copies it from
368the record. The code blocks below show the drafts. Where the record differs,
369the record wins, and the task's tests are updated to match.
370
371---
372
373### Task 1: Boxes publish shared-pool and inbound eligibility
374
375**Files:**
376- Modify: `internal/provision/reconcile.go` (beside `poolServed`, `:1339`)
377- Modify: `internal/host/edgesfile.go:155-206` (struct), `:600-611` (ident in `edgesFileLoop`)
378- Test: `internal/provision/reconcile_eligibility_test.go` (create), `internal/host/edgesfile_test.go`
379
380**Interfaces:**
381- Consumes: `provision.ReconcileConfig{Pools []string; Inbound string}`,
382 `provision.InboundNone`, `poolServed`, and `provisionTickConfig(getenv)
383 provision.ReconcileConfig` (`internal/host/agent.go:2030`).
384- Produces:
385 - `func (cfg ReconcileConfig) SharedInstanceEligibility() (sharedPool, inbound bool)`;
386 - `edgesFile.ServesSharedPool bool` (`json:"serves_shared_pool"`, not
387 omitempty);
388 - `edgesFile.AcceptsInbound bool` (`json:"accepts_inbound"`, not
389 omitempty).
390
391- [ ] **Step 1: Write the failing provision test**
392
393```go
394// internal/provision/reconcile_eligibility_test.go
395package provision
396
397import "testing"
398
399// SharedInstanceEligibility must be the reconciler's own predicates, never a
400// second reading of the environment (spec §6.1, codex R3-3).
401func TestSharedInstanceEligibilityIsTheMintPredicate(t *testing.T) {
402 cases := []struct {
403 pools []string
404 inbound string
405 shared, accept bool
406 }{
407 {nil, "", true, true},
408 {[]string{"tito"}, "", false, true},
409 {[]string{"", "tito"}, "", true, true},
410 {nil, InboundNone, true, false},
411 {[]string{"tito"}, InboundNone, false, false},
412 }
413 for _, c := range cases {
414 cfg := ReconcileConfig{Pools: c.pools, Inbound: c.inbound}
415 shared, inbound := cfg.SharedInstanceEligibility()
416 if shared != poolServed(c.pools, "") || inbound != (c.inbound != InboundNone) {
417 t.Fatalf("%+v: eligibility diverges from the mint predicates", c)
418 }
419 if shared != c.shared || inbound != c.accept {
420 t.Fatalf("%+v: got (%v, %v), want (%v, %v)", c, shared, inbound, c.shared, c.accept)
421 }
422 }
423}
424
425// spec §16, codex plan-R3 4: each published answer pairs with the reconcile
426// pass's own behaviour on an ORDINARY record (the shared "" pool, routed).
427// A box that publishes both true mints it; a box that publishes either
428// false refuses it, so the console's eligibility never offers a region
429// whose box would refuse the record.
430func TestEligibilityPairsWithTheReconcileRefusal(t *testing.T) {
431 for _, c := range []struct {
432 pools []string
433 inbound string
434 }{
435 {nil, ""}, {[]string{"tito"}, ""}, {[]string{"", "tito"}, ""},
436 {nil, InboundNone}, {[]string{"tito"}, InboundNone},
437 } {
438 s, sqid := setup(t)
439 if err := PutManifest(ctx, s, sqid, "tito", Manifest{Schema: 1, App: "tito", Domain: "go.tito.io"}); err != nil {
440 t.Fatal(err)
441 }
442 rec := acmeRecord(sqid)
443 rec.Pool = ""
444 if err := CreateRecord(ctx, s, rec); err != nil {
445 t.Fatal(err)
446 }
447 reg := regWith(t)
448 cfg := ReconcileConfig{Region: "ie", Pools: c.pools, DataDir: "/data", Inbound: c.inbound}
449 if err := Reconcile(ctx, s, reg, cfg); err != nil {
450 t.Fatal(err)
451 }
452 rt, err := reg.Get(rec.Host)
453 minted := err == nil && rt != nil
454 shared, inbound := cfg.SharedInstanceEligibility()
455 if minted != (shared && inbound) {
456 t.Errorf("pools %q inbound %q: minted %v, but the box publishes serves_shared_pool=%v accepts_inbound=%v", c.pools, c.inbound, minted, shared, inbound)
457 }
458 }
459}
460```
461
462`setup`, `acmeRecord`, `regWith` and `ctx` are `reconcile_test.go`'s (same
463package). `setup`'s manifest is pooled, so the test replaces it with a
464shared-pool one before creating the ordinary record.
465
466- [ ] **Step 2: Run it to verify it fails**
467
468Run: `go test ./internal/provision -run 'TestSharedInstanceEligibilityIsTheMintPredicate|TestEligibilityPairsWithTheReconcileRefusal' -count=1`
469Expected: FAIL, `cfg.SharedInstanceEligibility undefined`.
470
471- [ ] **Step 3: Implement it beside `poolServed`**
472
473```go
474// SharedInstanceEligibility answers, from this box's own reconcile config,
475// whether it mints an ORDINARY instance record: one in the shared ""
476// pool, needing inbound service. The edges file publishes both answers
477// (serves_shared_pool, accepts_inbound) so the console never offers an
478// automatic choice of a region whose boxes would refuse the record
479// (nearest-region spec §6.1). It is the reconciler's own predicates —
480// poolServed and the CARLOS_INBOUND=none refusal at reconcile.go:925 — so
481// publication and minting cannot disagree.
482func (cfg ReconcileConfig) SharedInstanceEligibility() (sharedPool, inbound bool) {
483 return poolServed(cfg.Pools, ""), cfg.Inbound != InboundNone
484}
485```
486
487- [ ] **Step 4: Run it to verify it passes**
488
489Run: `go test ./internal/provision -run 'TestSharedInstanceEligibilityIsTheMintPredicate|TestEligibilityPairsWithTheReconcileRefusal' -count=1`
490Expected: PASS.
491
492Then a mutation check on the pairing: make `SharedInstanceEligibility`
493return `true, true`, and re-run `TestEligibilityPairsWithTheReconcileRefusal`.
494It must FAIL on every config the reconciler refuses. Revert, and re-run:
495PASS.
496
497- [ ] **Step 5: Write the failing edges-file tests**
498
499Append to `internal/host/edgesfile_test.go`:
500
501```go
502// The two eligibility fields are NOT omitempty: absent means "a writer
503// that predates the field" (unknown), false means "no" (spec §6.1).
504func TestEdgesFileRawWireBodyEligibilityFields(t *testing.T) {
505 for _, want := range []bool{true, false} {
506 s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
507 seedRoute(t, reg, "app.example.com")
508 var last []byte
509 ident := edgesFile{Box: "quiet-harbour", Region: "eu-west-2", Placement: "uk",
510 ServesSharedPool: want, AcceptsInbound: want}
511 if err := edgesFilePass(context.Background(), s, reg, ident, "203.0.113.10", &last); err != nil {
512 t.Fatal(err)
513 }
514 got, _, err := s.Get(context.Background(), "control/edges/quiet-harbour.json")
515 if err != nil {
516 t.Fatal(err)
517 }
518 var raw map[string]any
519 if err := json.Unmarshal(got, &raw); err != nil {
520 t.Fatal(err)
521 }
522 for _, k := range []string{"serves_shared_pool", "accepts_inbound"} {
523 v, present := raw[k]
524 if !present || v != want {
525 t.Fatalf("%s = %v (present %v), want %v always published", k, v, present, want)
526 }
527 }
528 }
529}
530
531// What the loop publishes is the reconcile config's own answer, for every
532// spelling an operator might write. CARLOS_POOLS=",tito" parses to
533// ["tito"] (agent.go's parser drops empty elements), so it does NOT serve
534// the shared pool and must publish false (codex R3-3).
535func TestEdgesFileEligibilityFollowsTheReconcileConfig(t *testing.T) {
536 cases := []struct {
537 pools, inbound string
538 shared, accept bool
539 }{
540 {"", "", true, true},
541 {"tito", "", false, true},
542 {",tito", "", false, true},
543 {"", "none", true, false},
544 {"tito", "NONE", false, false},
545 }
546 for _, c := range cases {
547 getenv := func(k string) string {
548 switch k {
549 case "CARLOS_POOLS":
550 return c.pools
551 case "CARLOS_INBOUND":
552 return c.inbound
553 }
554 return ""
555 }
556 shared, inbound := edgesFileEligibility(getenv)
557 cfg := provisionTickConfig(getenv)
558 wantShared, wantInbound := cfg.SharedInstanceEligibility()
559 if shared != wantShared || inbound != wantInbound {
560 t.Fatalf("%+v: published (%v, %v) but the reconciler says (%v, %v)", c, shared, inbound, wantShared, wantInbound)
561 }
562 if shared != c.shared || inbound != c.accept {
563 t.Fatalf("%+v: got (%v, %v), want (%v, %v)", c, shared, inbound, c.shared, c.accept)
564 }
565 }
566}
567```
568
569- [ ] **Step 6: Run them to verify they fail**
570
571Run: `socktest ./internal/host -run 'TestEdgesFile(RawWireBodyEligibilityFields|EligibilityFollowsTheReconcileConfig)' -count=1`
572Expected: FAIL, unknown fields `ServesSharedPool` and `AcceptsInbound`, and
573`edgesFileEligibility` undefined.
574
575- [ ] **Step 7: Implement the fields, the helper and the loop wiring**
576
577In `edgesFile`, after `HibernationConfigured`:
578
579```go
580 // ServesSharedPool and AcceptsInbound are this box's own answers to
581 // "would my reconcile pass mint an ORDINARY instance record" — one in
582 // the shared "" pool, needing inbound service (nearest-region spec
583 // §6.1). Deliberately NOT omitempty, HibernationConfigured's precedent:
584 // absent is a writer that predates the field (unknown), false is a box
585 // that said no. The console needs both positively true before it makes
586 // an AUTOMATIC choice of this box's region; explicit requests ignore
587 // them. Computed from the same ReconcileConfig the reconcile pass uses
588 // (edgesFileEligibility), never a second reading of the environment.
589 ServesSharedPool bool `json:"serves_shared_pool"`
590 AcceptsInbound bool `json:"accepts_inbound"`
591```
592
593Below `edgesFileRegion`:
594
595```go
596// edgesFileEligibility derives the two published eligibility answers from
597// the reconcile config this box actually runs with — provisionTickConfig,
598// the same parse the provision tick uses — so a spelling the reconciler
599// reads one way can never be published another.
600func edgesFileEligibility(getenv func(string) string) (sharedPool, inbound bool) {
601 return provisionTickConfig(getenv).SharedInstanceEligibility()
602}
603```
604
605In `edgesFileLoop`, build the ident with the two new fields:
606
607```go
608 sharedPool, inbound := edgesFileEligibility(os.Getenv)
609 ident := edgesFile{
610 Box: box,
611 Region: region,
612 Placement: strings.TrimSpace(os.Getenv("CARLOS_REGION")),
613 DataBucket: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_BUCKET")),
614 DataBucketRegion: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_REGION")),
615 // Same env, same trim, one line apart, deliberately: this boolean
616 // must never disagree with the bucket beside it, and the way to
617 // guarantee that is to derive both from the one read rather than to
618 // ask two questions that could drift.
619 HibernationConfigured: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_BUCKET")) != "",
620 ServesSharedPool: sharedPool,
621 AcceptsInbound: inbound,
622 }
623```
624
625- [ ] **Step 8: Run the host package to verify it passes**
626
627First update the four raw-wire goldens in `internal/host/edgesfile_test.go`,
628because the schema really did grow and both keys are always published
629(`:315`, `:357`, `:376` and `:929`, in `TestEdgesFileRawWireBodyMatchesSchema`,
630`TestEdgesFileRawWireBodyRegionField` and
631`TestEdgesFileRawWireBodyPlacementField`):
632
633```bash
634sed -i 's/"hibernation_configured":false,"generation":"` +/"hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"generation":"` +/' internal/host/edgesfile_test.go
635test "$(grep -c '"serves_shared_pool":false,"accepts_inbound":false' internal/host/edgesfile_test.go)" = 4
636```
637
638Run: `socktest ./internal/host -count=1 && go test ./internal/provision -count=1`
639Expected: PASS. Without the golden update, those three tests fail on exactly
640the two new keys (seen in the round-3 proof).
641
642- [ ] **Step 9: Cross-compile, then commit**
643
644Run:
645
646```bash
647fail=0
648for t in darwin/arm64 darwin/amd64 linux/arm64 windows/amd64 windows/arm64; do
649 CGO_ENABLED=0 GOOS=${t%/*} GOARCH=${t#*/} go build ./... || { echo "FAILED $t"; fail=1; }
650done
651[ "$fail" = 0 ] || exit 1 # stops this block before any commit below it
652```
653
654Expected: no `FAILED` line. `internal/host` is linked into the CLI.
655
656```bash
657git add internal/provision/reconcile.go internal/provision/reconcile_eligibility_test.go internal/host/edgesfile.go internal/host/edgesfile_test.go
658git commit -m "edges file: publish serves_shared_pool and accepts_inbound (nearest-region §6.1)
659
660Computed from the reconcile config the box mints with, so an automatic
661region choice can never land on a box that refuses ordinary instances.
662
663Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
664```
665
666---
667
668### Task 1b: Boxes publish their secrets-key fingerprint (R9)
669
670**Files:**
671- Create: `internal/appconfig/fingerprint.go`, `internal/appconfig/fingerprint_test.go`
672- Modify: `internal/host/edgesfile.go` (the struct after `AcceptsInbound`, `edgesFileLoop`'s ident)
673- Test: `internal/host/edgesfile_test.go`
674
675**Interfaces:**
676- Consumes: `appconfig.ParsePrivateKey`, `appconfig.ParsePublicKey`
677 (`internal/appconfig/crypto.go:59`, `:73`), and Task 1's `edgesFile`.
678- Produces:
679 - `func KeyFingerprint(pub *ecdh.PublicKey) string`, which returns
680 `"sha256:" + lowercase hex(sha256(pub.Bytes()))`;
681 - `func PublicFingerprint(b64 string) (string, error)`, the fingerprint of
682 a base64 `CARLOS_SECRETS_PUBKEY`;
683 - `edgesFile.SecretsKeyFP string` (`json:"secrets_key_fp,omitempty"`);
684 - `func edgesFileSecretsFP(getenv func(string) string) string`.
685
686The field name and its omitempty shape are the move-any-app spec's
687(`design-moves-any-region` at e41c4fef, §6). The format is fixed by this
688spec's §6.1 for both, and `KeyFingerprint` is its only renderer.
689
690- [ ] **Step 1: Write the failing `appconfig` test**
691
692```go
693// internal/appconfig/fingerprint_test.go
694package appconfig
695
696import (
697 "bytes"
698 "crypto/ecdh"
699 "encoding/base64"
700 "regexp"
701 "testing"
702)
703
704// A box computes the fingerprint from its PRIVATE key's public half; the
705// console computes it from CARLOS_SECRETS_PUBKEY. They must agree byte for
706// byte (nearest-region spec §6.1, R9).
707func TestKeyFingerprintAgreesAcrossHalves(t *testing.T) {
708 priv, err := ecdh.P256().NewPrivateKey(bytes.Repeat([]byte{1}, 32))
709 if err != nil {
710 t.Fatal(err)
711 }
712 fromPriv := KeyFingerprint(priv.PublicKey())
713 fromPub, err := PublicFingerprint(base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes()))
714 if err != nil {
715 t.Fatal(err)
716 }
717 if fromPriv != fromPub {
718 t.Fatalf("private half %q != public half %q", fromPriv, fromPub)
719 }
720 if !regexp.MustCompile(`^sha256:[0-9a-f]{64}$`).MatchString(fromPriv) {
721 t.Fatalf("format = %q", fromPriv)
722 }
723 other, _ := ecdh.P256().NewPrivateKey(bytes.Repeat([]byte{2}, 32))
724 if KeyFingerprint(other.PublicKey()) == fromPriv {
725 t.Fatal("two keys share a fingerprint")
726 }
727 if _, err := PublicFingerprint("not base64!"); err == nil {
728 t.Fatal("garbage parsed")
729 }
730}
731```
732
733- [ ] **Step 2: Run it to verify it fails**
734
735Run: `go test ./internal/appconfig -run TestKeyFingerprint -count=1`
736Expected: FAIL, undefined `KeyFingerprint`.
737
738- [ ] **Step 3: Implement `fingerprint.go`**
739
740```go
741package appconfig
742
743import (
744 "crypto/ecdh"
745 "crypto/sha256"
746 "encoding/hex"
747)
748
749// KeyFingerprint names a secrets keypair without revealing either half:
750// "sha256:" and the lowercase hex SHA-256 of the public key's uncompressed
751// SEC1 bytes — the same bytes CARLOS_SECRETS_PUBKEY base64-decodes to. It
752// is the ONE renderer of the edges file's secrets_key_fp, shared by the
753// nearest-region spec (§6.1, R9) and the move-any-app spec (§6, T2): a box
754// that can open this deployment's sealed config publishes exactly the
755// string the console derives from its own public key.
756func KeyFingerprint(pub *ecdh.PublicKey) string {
757 sum := sha256.Sum256(pub.Bytes())
758 return "sha256:" + hex.EncodeToString(sum[:])
759}
760
761// PublicFingerprint is KeyFingerprint of a base64 CARLOS_SECRETS_PUBKEY.
762func PublicFingerprint(b64 string) (string, error) {
763 pub, err := ParsePublicKey(b64, "CARLOS_SECRETS_PUBKEY")
764 if err != nil {
765 return "", err
766 }
767 return KeyFingerprint(pub), nil
768}
769```
770
771- [ ] **Step 4: Run it to verify it passes**
772
773Run: `go test ./internal/appconfig -run TestKeyFingerprint -count=1`
774Expected: PASS.
775
776- [ ] **Step 5: Write the failing edges-file tests**
777
778Append to `internal/host/edgesfile_test.go`:
779
780```go
781// R9: the box publishes the fingerprint of its secrets key's public half,
782// never the key; no key, or a key that does not parse, publishes nothing.
783func TestEdgesFileSecretsKeyFingerprint(t *testing.T) {
784 priv, err := ecdh.P256().NewPrivateKey(bytes.Repeat([]byte{1}, 32))
785 if err != nil {
786 t.Fatal(err)
787 }
788 privB64 := base64.StdEncoding.EncodeToString(priv.Bytes())
789 pubB64 := base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes())
790 env := func(v string) func(string) string {
791 return func(k string) string {
792 if k == "CARLOS_SECRETS_KEY" {
793 return v
794 }
795 return ""
796 }
797 }
798 want := appconfig.KeyFingerprint(priv.PublicKey())
799 if got := edgesFileSecretsFP(env(privB64)); got != want {
800 t.Fatalf("with a key = %q, want %q", got, want)
801 }
802 for _, v := range []string{"", "not base64!", base64.StdEncoding.EncodeToString([]byte("short"))} {
803 if got := edgesFileSecretsFP(env(v)); got != "" {
804 t.Errorf("CARLOS_SECRETS_KEY=%q published %q, want nothing", v, got)
805 }
806 }
807
808 for _, fp := range []string{want, ""} {
809 s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
810 seedRoute(t, reg, "app.example.com")
811 var last []byte
812 ident := edgesFile{Box: "quiet-harbour", Region: "eu-west-2", Placement: "uk", SecretsKeyFP: fp}
813 if err := edgesFilePass(context.Background(), s, reg, ident, "203.0.113.10", &last); err != nil {
814 t.Fatal(err)
815 }
816 body, _, err := s.Get(context.Background(), "control/edges/quiet-harbour.json")
817 if err != nil {
818 t.Fatal(err)
819 }
820 has := strings.Contains(string(body), `"secrets_key_fp"`)
821 if has != (fp != "") {
822 t.Fatalf("fp %q: key present = %v in %s", fp, has, body)
823 }
824 for _, secret := range []string{privB64, pubB64} {
825 if strings.Contains(string(body), secret) {
826 t.Fatal("the edges file carries key material")
827 }
828 }
829 }
830}
831```
832
833Add `bytes`, `crypto/ecdh`, `encoding/base64` and
834`github.com/carlosframework/platform/internal/appconfig` to the test file's
835imports.
836
837- [ ] **Step 6: Run it to verify it fails**
838
839Run: `socktest ./internal/host -run TestEdgesFileSecretsKeyFingerprint -count=1`
840Expected: FAIL, unknown field `SecretsKeyFP`.
841
842- [ ] **Step 7: Implement the field, the helper and the loop wiring**
843
844In `edgesFile`, after `AcceptsInbound`:
845
846```go
847 // SecretsKeyFP is the fingerprint of this box's CARLOS_SECRETS_KEY's
848 // public half (appconfig.KeyFingerprint), NEVER the key. Additive and
849 // omitempty, the move-any-app spec's shape: absent means this box holds
850 // no key, or one that does not parse — so it renders no exec config,
851 // and an instance placed here would run with none (nearest-region spec
852 // R9). The console requires it to equal the fingerprint of its own
853 // CARLOS_SECRETS_PUBKEY before it places anything here automatically.
854 SecretsKeyFP string `json:"secrets_key_fp,omitempty"`
855```
856
857Below `edgesFileEligibility`:
858
859```go
860// edgesFileSecretsFP fingerprints the box's secrets key, read the same way
861// the config loops read it (agent.go configSyncLoop). The key itself never
862// leaves this function.
863func edgesFileSecretsFP(getenv func(string) string) string {
864 raw := strings.TrimSpace(getenv("CARLOS_SECRETS_KEY"))
865 if raw == "" {
866 return ""
867 }
868 priv, err := appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY")
869 if err != nil {
870 log.Printf("agent: CARLOS_SECRETS_KEY does not parse, so no secrets_key_fp is published: %v", err)
871 return ""
872 }
873 return appconfig.KeyFingerprint(priv.PublicKey())
874}
875```
876
877The log text is O10 from the copy record. `ParsePrivateKey`'s errors name
878the variable and the parse failure, never the value.
879
880Add `"github.com/carlosframework/platform/internal/appconfig"` to
881`edgesfile.go`'s imports.
882
883In `edgesFileLoop`'s ident literal, add:
884
885```go
886 SecretsKeyFP: edgesFileSecretsFP(os.Getenv),
887```
888
889- [ ] **Step 8: Run the tests to verify they pass, then cross-compile**
890
891Run:
892
893```bash
894socktest ./internal/host -count=1 && go test ./internal/appconfig -count=1
895fail=0
896for t in darwin/arm64 darwin/amd64 linux/arm64 windows/amd64 windows/arm64; do
897 CGO_ENABLED=0 GOOS=${t%/*} GOARCH=${t#*/} go build ./... || { echo "FAILED $t"; fail=1; }
898done
899[ "$fail" = 0 ] || exit 1 # stops this block before any commit below it
900```
901
902Expected: PASS, and no `FAILED` line.
903
904- [ ] **Step 9: Commit**
905
906```bash
907git add internal/appconfig/fingerprint.go internal/appconfig/fingerprint_test.go internal/host/edgesfile.go internal/host/edgesfile_test.go
908git commit -m "edges file: publish secrets_key_fp, the fingerprint of the box's secrets key (nearest-region R9)
909
910Same field and shape as the move-any-app spec; appconfig.KeyFingerprint
911is the one renderer both use.
912
913Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
914```
915
916---
917
918### Task 2: `internal/geo` points, distance and the nearest pick
919
920**Files:**
921- Create: `internal/geo/point.go`, `internal/geo/nearest.go`
922- Test: `internal/geo/nearest_test.go`
923
924**Interfaces:**
925- Consumes: `LookupAWSRegion` from Task 4. To keep this task testable on its
926 own, `Nearest` takes a `pointOf func(awsRegion string) (Point, bool)`
927 parameter, and Task 4 adds the exported wrapper.
928- Produces:
929 - `type Point struct{ Lat, Lon float64 }`;
930 - `func DistanceKm(a, b Point) float64`;
931 - `type Candidate struct{ Code string; Home bool; AWSRegion string }`;
932 - `func NearestWith(p Point, cands []Candidate, pointOf func(string) (Point, bool)) (code string, ok bool)`.
933
934- [ ] **Step 1: Write the failing tests**
935
936```go
937// internal/geo/nearest_test.go
938package geo
939
940import (
941 "math"
942 "testing"
943)
944
945var testPoints = map[string]Point{
946 "eu-west-1": {53.35, -6.26},
947 "eu-west-2": {51.51, -0.13},
948 "ap-southeast-2": {-33.87, 151.21},
949}
950
951func testPointOf(r string) (Point, bool) { p, ok := testPoints[r]; return p, ok }
952
953func TestDistanceKmDublinLondon(t *testing.T) {
954 d := DistanceKm(testPoints["eu-west-1"], testPoints["eu-west-2"])
955 if math.Abs(d-464) > 5 {
956 t.Fatalf("Dublin to London = %.1f km, want about 464", d)
957 }
958}
959
960func TestNearest(t *testing.T) {
961 london := Point{51.5, -0.12}
962 sydney := Point{-33.9, 151.2}
963 home := Candidate{Code: "", Home: true, AWSRegion: "eu-west-1"}
964 uk := Candidate{Code: "uk", AWSRegion: "eu-west-2"}
965 au := Candidate{Code: "au", AWSRegion: "ap-southeast-2"}
966
967 cases := []struct {
968 name string
969 p Point
970 cands []Candidate
971 want string
972 wantOK bool
973 }{
974 {"single", london, []Candidate{home}, "", true},
975 {"clear winner", london, []Candidate{home, uk, au}, "uk", true},
976 {"far side", sydney, []Candidate{home, uk, au}, "au", true},
977 {"home with no point gives no answer", london,
978 []Candidate{{Code: "", Home: true, AWSRegion: ""}, uk}, "", false},
979 {"non-home with no point is left out", london,
980 []Candidate{home, {Code: "zz", AWSRegion: "xx-nowhere-1"}}, "", true},
981 {"empty", london, nil, "", false},
982 {"tie goes to the home", testPoints["eu-west-2"],
983 []Candidate{{Code: "uk2", AWSRegion: "eu-west-2"}, {Code: "", Home: true, AWSRegion: "eu-west-2"}}, "", true},
984 {"non-home tie goes to the smaller code", testPoints["eu-west-2"],
985 []Candidate{{Code: "uk2", AWSRegion: "eu-west-2"}, {Code: "uk", AWSRegion: "eu-west-2"}}, "uk", true},
986 }
987 for _, c := range cases {
988 got, ok := NearestWith(c.p, c.cands, testPointOf)
989 if got != c.want || ok != c.wantOK {
990 t.Errorf("%s: got (%q, %v), want (%q, %v)", c.name, got, ok, c.want, c.wantOK)
991 }
992 }
993}
994```
995
996- [ ] **Step 2: Run them to verify they fail**
997
998Run: `go test ./internal/geo -count=1`
999Expected: FAIL, because the package does not exist.
1000
1001- [ ] **Step 3: Implement `point.go`**
1002
1003```go
1004// Package geo turns a timezone or an IP into a point on the globe and picks
1005// the nearest of a set of AWS regions (nearest-region spec §6). It holds
1006// points only for the length of one call: nothing here stores, logs or
1007// returns a caller's coordinates.
1008package geo
1009
1010import "math"
1011
1012// Point is a latitude and longitude in degrees.
1013type Point struct{ Lat, Lon float64 }
1014
1015// earthRadiusKm is the IUGG mean Earth radius.
1016const earthRadiusKm = 6371.0088
1017
1018// DistanceKm is the great-circle (haversine) distance between a and b.
1019func DistanceKm(a, b Point) float64 {
1020 rad := func(d float64) float64 { return d * math.Pi / 180 }
1021 dLat := rad(b.Lat - a.Lat)
1022 dLon := rad(b.Lon - a.Lon)
1023 h := math.Sin(dLat/2)*math.Sin(dLat/2) +
1024 math.Cos(rad(a.Lat))*math.Cos(rad(b.Lat))*math.Sin(dLon/2)*math.Sin(dLon/2)
1025 return 2 * earthRadiusKm * math.Asin(math.Sqrt(h))
1026}
1027```
1028
1029- [ ] **Step 4: Implement `nearest.go`**
1030
1031```go
1032package geo
1033
1034import "math"
1035
1036// Candidate is one region a guess may pick. Code is in the record
1037// vocabulary ("" is the deployment's home); the caller has already
1038// filtered to eligible, policy-allowed candidates.
1039type Candidate struct {
1040 Code string
1041 Home bool
1042 AWSRegion string
1043}
1044
1045// NearestWith returns the candidate nearest p, measured to each
1046// candidate's AWS region through pointOf.
1047//
1048// - A candidate whose region has no point is left out.
1049// - If the HOME is among cands and has no point, ok is false: picking
1050// among the rest could choose a far region over an unmeasured near
1051// home, and the home is where today's behaviour lives (spec §6.3).
1052// - Distances are compared in whole kilometres. A tie goes to the home,
1053// then to the lexically smallest code, so every console answers alike.
1054func NearestWith(p Point, cands []Candidate, pointOf func(string) (Point, bool)) (code string, ok bool) {
1055 best := -1
1056 bestKm := math.MaxInt
1057 for i, c := range cands {
1058 cp, has := pointOf(c.AWSRegion)
1059 if !has {
1060 if c.Home {
1061 return "", false
1062 }
1063 continue
1064 }
1065 km := int(math.Round(DistanceKm(p, cp)))
1066 switch {
1067 case best < 0, km < bestKm:
1068 case km == bestKm && better(c, cands[best]):
1069 default:
1070 continue
1071 }
1072 best, bestKm = i, km
1073 }
1074 if best < 0 {
1075 return "", false
1076 }
1077 return cands[best].Code, true
1078}
1079
1080// better breaks an exact tie: the home first, then the smaller code.
1081func better(a, b Candidate) bool {
1082 if a.Home != b.Home {
1083 return a.Home
1084 }
1085 return a.Code < b.Code
1086}
1087```
1088
1089- [ ] **Step 5: Run the tests to verify they pass**
1090
1091Run: `go test ./internal/geo -count=1`
1092Expected: PASS.
1093
1094- [ ] **Step 6: Commit**
1095
1096```bash
1097git add internal/geo/point.go internal/geo/nearest.go internal/geo/nearest_test.go
1098git commit -m "geo: haversine and the nearest-region pick with deterministic ties (nearest-region §6.3)
1099
1100Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
1101```
1102
1103---
1104
1105### Task 3: `internal/geo` timezones and country points
1106
1107**Files:**
1108- Create: `internal/geo/data/zone.tab`, `internal/geo/data/links.txt`, `internal/geo/data/VERSION`
1109- Create: `internal/geo/tz.go`, `scripts/update-tzgeo.sh`
1110- Test: `internal/geo/tz_test.go`
1111
1112**Interfaces:**
1113- Consumes: `Point` from Task 2.
1114- Produces:
1115 - `func ZonePoint(name string) (Point, bool)`;
1116 - `func CountryPoint(iso2 string) (Point, bool)`;
1117 - `func TZDataVersion() string`.
1118
1119- [ ] **Step 1: Write the refresh script**
1120
1121```bash
1122#!/usr/bin/env bash
1123# scripts/update-tzgeo.sh <version>, e.g. 2026b
1124#
1125# Refreshes internal/geo/data from one IANA tzdata release
1126# (nearest-region spec §6.2). zone.tab carries the coordinates; the Link
1127# lines resolve the old names browsers still report (Asia/Calcutta). Run it
1128# as a release-checklist step beside the Go toolchain bump; a stale table
1129# only misses zone names newer than it, and those fall through to IP.
1130set -euo pipefail
1131v="${1:?usage: update-tzgeo.sh <tzdata version, e.g. 2026b>}"
1132here="$(cd "$(dirname "$0")/.." && pwd)"
1133out="$here/internal/geo/data"
1134work="$(mktemp -d "${TMPDIR:-/tmp}/tzgeo.XXXXXX")"
1135trap 'rm -rf "$work"' EXIT
1136base="https://data.iana.org/time-zones/releases"
1137curl -fsSL -o "$work/tzdata.tar.gz" "$base/tzdata$v.tar.gz"
1138curl -fsSL -o "$work/tzdata.tar.gz.asc" "$base/tzdata$v.tar.gz.asc"
1139if command -v gpg >/dev/null; then
1140 gpg --verify "$work/tzdata.tar.gz.asc" "$work/tzdata.tar.gz"
1141else
1142 echo "gpg not found: signature NOT checked" >&2
1143 exit 1
1144fi
1145tar -xzf "$work/tzdata.tar.gz" -C "$work"
1146mkdir -p "$out"
1147cp "$work/zone.tab" "$out/zone.tab"
1148awk '$1 == "Link" { print $2, $3 }' \
1149 "$work"/africa "$work"/antarctica "$work"/asia "$work"/australasia \
1150 "$work"/backward "$work"/etcetera "$work"/europe "$work"/northamerica \
1151 "$work"/southamerica | sort -u > "$out/links.txt"
1152printf '%s\n' "$v" > "$out/VERSION"
1153echo "internal/geo/data refreshed to tzdata $v"
1154```
1155
1156`chmod +x scripts/update-tzgeo.sh`. Its three output lines are S1 to S3 from the copy record.
1157
1158- [ ] **Step 2: Generate the data**
1159
1160Run: `scripts/update-tzgeo.sh 2026b`. This needs network access to
1161`data.iana.org` and `gpg` with the IANA key; outside the sandbox, use
1162`dangerouslyDisableSandbox` for this one command.
1163
1164Expected: three files in `internal/geo/data/`. `links.txt` has lines like
1165`Asia/Kolkata Asia/Calcutta`, and `VERSION` contains `2026b`.
1166
1167- [ ] **Step 3: Write the failing tests**
1168
1169```go
1170// internal/geo/tz_test.go
1171package geo
1172
1173import (
1174 "strings"
1175 "testing"
1176)
1177
1178func TestZonePoint(t *testing.T) {
1179 mapped := []string{
1180 "Europe/London", "Europe/Dublin", "Europe/Amsterdam",
1181 "Asia/Kolkata", "Asia/Calcutta",
1182 "America/Argentina/Buenos_Aires", "europe/london",
1183 }
1184 for _, z := range mapped {
1185 if _, ok := ZonePoint(z); !ok {
1186 t.Errorf("ZonePoint(%q) unmapped, want a point", z)
1187 }
1188 }
1189 a, _ := ZonePoint("Asia/Calcutta")
1190 b, _ := ZonePoint("Asia/Kolkata")
1191 if a != b {
1192 t.Errorf("Asia/Calcutta %v != Asia/Kolkata %v; the link must resolve to one point", a, b)
1193 }
1194 unmapped := []string{
1195 "", "UTC", "Etc/UTC", "Etc/GMT+5", "GMT", "UCT", "Universal", "Zulu",
1196 "Factory", "Local", "localtime", "posixrules", "../etc/passwd",
1197 "Europe/London/Extra/Deep", strings.Repeat("A", 65), "Nowhere/Atlantis",
1198 }
1199 for _, z := range unmapped {
1200 if p, ok := ZonePoint(z); ok {
1201 t.Errorf("ZonePoint(%q) = %v, want unmappable", z, p)
1202 }
1203 }
1204 l, _ := ZonePoint("Europe/London")
1205 if l.Lat < 51 || l.Lat > 52 || l.Lon > 0.5 || l.Lon < -0.5 {
1206 t.Errorf("Europe/London = %v, want about 51.5, -0.12", l)
1207 }
1208}
1209
1210// A link cycle must terminate (at most 4 hops) rather than hang.
1211func TestZonePointLinkLoopTerminates(t *testing.T) {
1212 links := map[string]string{"a/x": "a/y", "a/y": "a/x"}
1213 if _, ok := resolveLinks("a/x", links, map[string]Point{}); ok {
1214 t.Fatal("a link loop resolved to a point")
1215 }
1216}
1217
1218// Every country with more than one zone.tab row needs an override naming
1219// its principal zone: a tzdata refresh that gives a country a second row
1220// fails here instead of silently averaging a continent (spec §6.2).
1221func TestCountryOverridesCoverEveryMultiZoneCountry(t *testing.T) {
1222 for cc, n := range zoneRowsPerCountry() {
1223 if n < 2 {
1224 continue
1225 }
1226 zone, ok := countryOverride[cc]
1227 if !ok {
1228 t.Errorf("country %s has %d zone.tab rows and no override", cc, n)
1229 continue
1230 }
1231 if _, ok := ZonePoint(zone); !ok {
1232 t.Errorf("override for %s names %q, which is not in zone.tab", cc, zone)
1233 }
1234 }
1235 for cc := range countryOverride {
1236 if zoneRowsPerCountry()[cc] < 2 {
1237 t.Errorf("override for %s is unused: it has fewer than two rows", cc)
1238 }
1239 }
1240}
1241
1242func TestCountryPoint(t *testing.T) {
1243 for _, cc := range []string{"GB", "IE", "US", "AU", "IN", "gb"} {
1244 if _, ok := CountryPoint(cc); !ok {
1245 t.Errorf("CountryPoint(%q) unmapped", cc)
1246 }
1247 }
1248 us, _ := CountryPoint("US")
1249 ny, _ := ZonePoint("America/New_York")
1250 if us != ny {
1251 t.Errorf("US = %v, want its principal zone New York %v", us, ny)
1252 }
1253 if _, ok := CountryPoint("ZZ"); ok {
1254 t.Error("CountryPoint(ZZ) mapped")
1255 }
1256}
1257
1258func TestTZDataVersion(t *testing.T) {
1259 if v := TZDataVersion(); v == "" || strings.ContainsAny(v, " \n") {
1260 t.Fatalf("TZDataVersion = %q", v)
1261 }
1262}
1263```
1264
1265- [ ] **Step 4: Run them to verify they fail**
1266
1267Run: `go test ./internal/geo -run 'TestZonePoint|TestCountry|TestTZDataVersion' -count=1`
1268Expected: FAIL, undefined `ZonePoint`.
1269
1270- [ ] **Step 5: Implement `tz.go`**
1271
1272```go
1273package geo
1274
1275import (
1276 _ "embed"
1277 "regexp"
1278 "strconv"
1279 "strings"
1280 "sync"
1281)
1282
1283//go:embed data/zone.tab
1284var zoneTab string
1285
1286//go:embed data/links.txt
1287var linksTxt string
1288
1289//go:embed data/VERSION
1290var tzVersion string
1291
1292// TZDataVersion is the IANA release the embedded tables came from.
1293func TZDataVersion() string { return strings.TrimSpace(tzVersion) }
1294
1295var (
1296 tzOnce sync.Once
1297 zonePoints map[string]Point // lowercased zone name -> point
1298 zoneLinks map[string]string
1299 countryOne map[string]Point // countries with exactly one row
1300 rowsPerCC map[string]int
1301)
1302
1303// zoneShape is spec §6.2 step 1: at most 64 bytes, one to three
1304// "/"-separated segments of [A-Za-z0-9_+-].
1305var zoneShape = regexp.MustCompile(`^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+){0,2}$`)
1306
1307// notAPlace names an offset or a machine, not a place (R3).
1308var notAPlace = map[string]bool{
1309 "": true, "local": true, "localtime": true, "posixrules": true,
1310 "utc": true, "uct": true, "gmt": true, "universal": true, "zulu": true,
1311 "factory": true,
1312}
1313
1314func loadTZ() {
1315 zonePoints = map[string]Point{}
1316 countryOne = map[string]Point{}
1317 rowsPerCC = map[string]int{}
1318 for _, line := range strings.Split(zoneTab, "\n") {
1319 if line == "" || strings.HasPrefix(line, "#") {
1320 continue
1321 }
1322 f := strings.Split(line, "\t")
1323 if len(f) < 3 {
1324 continue
1325 }
1326 p, ok := parseISO6709(f[1])
1327 if !ok {
1328 continue
1329 }
1330 zonePoints[strings.ToLower(f[2])] = p
1331 cc := strings.ToUpper(f[0])
1332 rowsPerCC[cc]++
1333 countryOne[cc] = p
1334 }
1335 zoneLinks = map[string]string{}
1336 for _, line := range strings.Split(linksTxt, "\n") {
1337 f := strings.Fields(line)
1338 if len(f) == 2 {
1339 zoneLinks[strings.ToLower(f[1])] = strings.ToLower(f[0])
1340 }
1341 }
1342}
1343
1344// ZonePoint maps an IANA zone name to its principal location, following
1345// tzdata links (at most 4 hops). It refuses anything that is not a place.
1346func ZonePoint(name string) (Point, bool) {
1347 tzOnce.Do(loadTZ)
1348 name = strings.TrimSpace(name)
1349 if len(name) > 64 || !zoneShape.MatchString(name) {
1350 return Point{}, false
1351 }
1352 low := strings.ToLower(name)
1353 if notAPlace[low] || strings.HasPrefix(low, "etc/") {
1354 return Point{}, false
1355 }
1356 return resolveLinks(low, zoneLinks, zonePoints)
1357}
1358
1359func resolveLinks(name string, links map[string]string, points map[string]Point) (Point, bool) {
1360 for hop := 0; hop <= 4; hop++ {
1361 if p, ok := points[name]; ok {
1362 return p, true
1363 }
1364 next, ok := links[name]
1365 if !ok {
1366 return Point{}, false
1367 }
1368 name = next
1369 }
1370 return Point{}, false
1371}
1372
1373// countryOverride names the principal zone of every country with more
1374// than one zone.tab row (tzdata 2026b). TestCountryOverridesCoverEvery
1375// MultiZoneCountry fails when a refresh adds a multi-row country.
1376var countryOverride = map[string]string{
1377 "AQ": "Antarctica/McMurdo", "AR": "America/Argentina/Buenos_Aires",
1378 "AU": "Australia/Sydney", "BR": "America/Sao_Paulo", "CA": "America/Toronto",
1379 "CD": "Africa/Kinshasa", "CL": "America/Santiago", "CN": "Asia/Shanghai",
1380 "CY": "Asia/Nicosia", "DE": "Europe/Berlin", "EC": "America/Guayaquil",
1381 "ES": "Europe/Madrid", "FM": "Pacific/Pohnpei", "GL": "America/Nuuk",
1382 "ID": "Asia/Jakarta", "KI": "Pacific/Tarawa", "KZ": "Asia/Almaty",
1383 "MH": "Pacific/Majuro", "MN": "Asia/Ulaanbaatar", "MX": "America/Mexico_City",
1384 "MY": "Asia/Kuala_Lumpur", "NZ": "Pacific/Auckland", "PF": "Pacific/Tahiti",
1385 "PG": "Pacific/Port_Moresby", "PS": "Asia/Gaza", "PT": "Europe/Lisbon",
1386 "RU": "Europe/Moscow", "UA": "Europe/Kyiv", "UM": "Pacific/Wake",
1387 "US": "America/New_York", "UZ": "Asia/Tashkent",
1388}
1389
1390// CountryPoint maps an ISO 3166-1 alpha-2 code to a point: its only
1391// zone.tab row, or its override's zone.
1392func CountryPoint(iso2 string) (Point, bool) {
1393 tzOnce.Do(loadTZ)
1394 cc := strings.ToUpper(strings.TrimSpace(iso2))
1395 if zone, ok := countryOverride[cc]; ok {
1396 return ZonePoint(zone)
1397 }
1398 if rowsPerCC[cc] == 1 {
1399 return countryOne[cc], true
1400 }
1401 return Point{}, false
1402}
1403
1404func zoneRowsPerCountry() map[string]int { tzOnce.Do(loadTZ); return rowsPerCC }
1405
1406// parseISO6709 reads zone.tab's ±DDMM±DDDMM or ±DDMMSS±DDDMMSS.
1407func parseISO6709(s string) (Point, bool) {
1408 i := strings.IndexAny(s[1:], "+-") + 1
1409 if i <= 0 {
1410 return Point{}, false
1411 }
1412 lat, ok1 := dms(s[:i], 2)
1413 lon, ok2 := dms(s[i:], 3)
1414 return Point{lat, lon}, ok1 && ok2
1415}
1416
1417func dms(s string, degDigits int) (float64, bool) {
1418 sign := 1.0
1419 if s[0] == '-' {
1420 sign = -1
1421 }
1422 d := s[1:]
1423 if len(d) != degDigits+2 && len(d) != degDigits+4 {
1424 return 0, false
1425 }
1426 deg, err1 := strconv.Atoi(d[:degDigits])
1427 min, err2 := strconv.Atoi(d[degDigits : degDigits+2])
1428 sec := 0
1429 var err3 error
1430 if len(d) == degDigits+4 {
1431 sec, err3 = strconv.Atoi(d[degDigits+2:])
1432 }
1433 if err1 != nil || err2 != nil || err3 != nil {
1434 return 0, false
1435 }
1436 return sign * (float64(deg) + float64(min)/60 + float64(sec)/3600), true
1437}
1438```
1439
1440- [ ] **Step 6: Run the tests to verify they pass**
1441
1442Run: `go test ./internal/geo -count=1`
1443Expected: PASS. If `TestCountryOverridesCoverEveryMultiZoneCountry` names a
1444country, the downloaded tzdata differs from 2026b. Add or remove that
1445override using the principal zone, which is the country's capital or largest
1446city.
1447
1448- [ ] **Step 7: Commit**
1449
1450```bash
1451git add internal/geo/tz.go internal/geo/tz_test.go internal/geo/data scripts/update-tzgeo.sh
1452git commit -m "geo: embedded IANA zone.tab and links, zone and country points (nearest-region §6.2)
1453
1454Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
1455```
1456
1457---
1458
1459### Task 4: AWS region table and place names (C23, from the approved record)
1460
1461**Files:**
1462- Create: `internal/geo/awsregions.go`
1463- Test: `internal/geo/awsregions_test.go`
1464
1465**Interfaces:**
1466- Consumes:
1467 - `Point` and `NearestWith` (Task 2);
1468 - the record's `C23-*` entries (Task 0).
1469- Produces:
1470 - `type AWSRegion struct{ Point; Place string }`;
1471 - `func LookupAWSRegion(code string) (AWSRegion, bool)`;
1472 - `func Nearest(p Point, cands []Candidate) (string, bool)`;
1473 - `func PlaceName(awsRegion, fallback string) string`.
1474
1475Tests elsewhere name a place through `geo.PlaceName("eu-west-2", "uk")`,
1476never a literal, so the approved text can differ from the draft without
1477breaking them.
1478
1479- [ ] **Step 1: Write the failing tests**
1480
1481```go
1482// internal/geo/awsregions_test.go
1483package geo
1484
1485import (
1486 "regexp"
1487 "strings"
1488 "testing"
1489)
1490
1491func TestAWSRegionTable(t *testing.T) {
1492 shape := regexp.MustCompile(`^[a-z]{2}(-[a-z]+)+-\d$`)
1493 for code, r := range awsRegions {
1494 if !shape.MatchString(code) {
1495 t.Errorf("%s is not an AWS region code", code)
1496 }
1497 if r.Lat < -90 || r.Lat > 90 || r.Lon < -180 || r.Lon > 180 {
1498 t.Errorf("%s: %v out of range", code, r.Point)
1499 }
1500 if r.Place == "" || strings.Contains(r.Place, "—") {
1501 t.Errorf("%s: place %q empty or contains an em dash", code, r.Place)
1502 }
1503 }
1504 if len(awsRegions) != 34 {
1505 t.Errorf("%d regions, want the 34 of spec C23", len(awsRegions))
1506 }
1507 if PlaceName("xx-nowhere-1", "zz") != "zz" || PlaceName("", "ie") != "ie" {
1508 t.Error("PlaceName must fall back to the given code")
1509 }
1510 if r, ok := LookupAWSRegion("eu-west-2"); !ok || PlaceName("eu-west-2", "uk") != r.Place {
1511 t.Error("PlaceName must read the table")
1512 }
1513}
1514
1515func TestNearestUsesTheTable(t *testing.T) {
1516 london, _ := ZonePoint("Europe/London")
1517 got, ok := Nearest(london, []Candidate{
1518 {Code: "", Home: true, AWSRegion: "eu-west-1"},
1519 {Code: "uk", AWSRegion: "eu-west-2"},
1520 })
1521 if !ok || got != "uk" {
1522 t.Fatalf("Nearest from London = (%q, %v), want uk", got, ok)
1523 }
1524}
1525```
1526
1527- [ ] **Step 2: Run them to verify they fail**
1528
1529Run: `go test ./internal/geo -run 'TestAWSRegionTable|TestNearestUsesTheTable' -count=1`
1530Expected: FAIL, undefined `awsRegions`.
1531
1532- [ ] **Step 3: Implement the table**
1533
1534The `Place` strings below are the drafts Task 0 submitted. Write the
1535record's `C23-<code>` text for each in their place.
1536
1537```go
1538package geo
1539
1540// AWSRegion is one AWS commercial region's metro-area point and the place
1541// name every surface shows for it (spec §14, copy C23 as approved in the
1542// nearest-region copy record).
1543//
1544// Source: AWS's official region list
1545// (https://docs.aws.amazon.com/global-infrastructure/latest/regions/aws-regions.html)
1546// as of 2026-10-01. Coordinates are each region's metro area, checked by
1547// hand; AWS publishes no facility locations, and tens of kilometres never
1548// change a choice between regions.
1549type AWSRegion struct {
1550 Point
1551 Place string
1552}
1553
1554var awsRegions = map[string]AWSRegion{
1555 "us-east-1": {Point{39.04, -77.49}, "US East · N. Virginia"},
1556 "us-east-2": {Point{39.96, -83.00}, "US East · Ohio"},
1557 "us-west-1": {Point{37.77, -122.42}, "US West · N. California"},
1558 "us-west-2": {Point{45.84, -119.70}, "US West · Oregon"},
1559 "ca-central-1": {Point{45.50, -73.57}, "Canada · Montréal"},
1560 "ca-west-1": {Point{51.05, -114.07}, "Canada · Calgary"},
1561 "mx-central-1": {Point{20.59, -100.39}, "Mexico · Querétaro"},
1562 "sa-east-1": {Point{-23.55, -46.63}, "Brazil · São Paulo"},
1563 "eu-west-1": {Point{53.35, -6.26}, "Ireland · Dublin"},
1564 "eu-west-2": {Point{51.51, -0.13}, "UK · London"},
1565 "eu-west-3": {Point{48.86, 2.35}, "France · Paris"},
1566 "eu-central-1": {Point{50.11, 8.68}, "Germany · Frankfurt"},
1567 "eu-central-2": {Point{47.37, 8.54}, "Switzerland · Zurich"},
1568 "eu-south-1": {Point{45.46, 9.19}, "Italy · Milan"},
1569 "eu-south-2": {Point{41.65, -0.89}, "Spain · Aragón"},
1570 "eu-north-1": {Point{59.33, 18.07}, "Sweden · Stockholm"},
1571 "il-central-1": {Point{32.09, 34.78}, "Israel · Tel Aviv"},
1572 "me-south-1": {Point{26.23, 50.59}, "Bahrain"},
1573 "me-central-1": {Point{25.20, 55.27}, "UAE"},
1574 "af-south-1": {Point{-33.92, 18.42}, "South Africa · Cape Town"},
1575 "ap-south-1": {Point{19.08, 72.88}, "India · Mumbai"},
1576 "ap-south-2": {Point{17.39, 78.49}, "India · Hyderabad"},
1577 "ap-east-1": {Point{22.32, 114.17}, "Hong Kong"},
1578 "ap-east-2": {Point{25.03, 121.57}, "Taiwan · Taipei"},
1579 "ap-southeast-1": {Point{1.35, 103.82}, "Singapore"},
1580 "ap-southeast-2": {Point{-33.87, 151.21}, "Australia · Sydney"},
1581 "ap-southeast-3": {Point{-6.21, 106.85}, "Indonesia · Jakarta"},
1582 "ap-southeast-4": {Point{-37.81, 144.96}, "Australia · Melbourne"},
1583 "ap-southeast-5": {Point{3.14, 101.69}, "Malaysia"},
1584 "ap-southeast-6": {Point{-36.85, 174.76}, "New Zealand · Auckland"},
1585 "ap-southeast-7": {Point{13.76, 100.50}, "Thailand · Bangkok"},
1586 "ap-northeast-1": {Point{35.68, 139.69}, "Japan · Tokyo"},
1587 "ap-northeast-2": {Point{37.57, 126.98}, "South Korea · Seoul"},
1588 "ap-northeast-3": {Point{34.69, 135.50}, "Japan · Osaka"},
1589}
1590
1591// LookupAWSRegion finds a region in the table.
1592func LookupAWSRegion(code string) (AWSRegion, bool) {
1593 r, ok := awsRegions[code]
1594 return r, ok
1595}
1596
1597func tablePoint(code string) (Point, bool) {
1598 r, ok := awsRegions[code]
1599 return r.Point, ok
1600}
1601
1602// Nearest is NearestWith over the shipped table.
1603func Nearest(p Point, cands []Candidate) (string, bool) {
1604 return NearestWith(p, cands, tablePoint)
1605}
1606
1607// PlaceName is the table's place for awsRegion, or fallback (the bare
1608// placement code) for a region the table does not know.
1609func PlaceName(awsRegion, fallback string) string {
1610 if r, ok := awsRegions[awsRegion]; ok {
1611 return r.Place
1612 }
1613 return fallback
1614}
1615```
1616
1617- [ ] **Step 4: Run the tests to verify they pass**
1618
1619Run: `go test ./internal/geo -count=1`
1620Expected: PASS.
1621
1622- [ ] **Step 5: Check the coordinates by hand**
1623
1624For each row, confirm the coordinates fall within the named metro area, and
1625that the 34 codes match AWS's current list. Correct any row that does not,
1626then re-run step 4.
1627
1628- [ ] **Step 6: Commit**
1629
1630```bash
1631git add internal/geo/awsregions.go internal/geo/awsregions_test.go
1632git commit -m "geo: AWS region table with approved place names (nearest-region §14)
1633
1634Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
1635```
1636
1637---
1638
1639### Task 5: Optional GeoIP locator
1640
1641**Files:**
1642- Create: `internal/geo/mmdb.go`
1643- Create, under `internal/geo/testdata/`:
1644 - `GeoIP2-City-Test.mmdb`, `GeoIP2-Country-Test.mmdb`, `MaxMind-DB-test-ipv4-24.mmdb`;
1645 - `GeoIP2-Country-Test.json`;
1646 - `LICENSE-MIT`, `LICENSE-APACHE`;
1647 - `README`.
1648- Modify: `go.mod`, `go.sum`
1649- Test: `internal/geo/mmdb_test.go`
1650
1651**Interfaces:**
1652- Consumes: `Point` and `CountryPoint` (Task 3), and O5 from the copy
1653 record.
1654- Produces:
1655 - `type Locator interface{ Locate(ip net.IP) (Point, bool) }`;
1656 - `func OpenMMDB(path string) (*MMDB, error)`;
1657 - `func (m *MMDB) Locate(ip net.IP) (Point, bool)`;
1658 - `func (m *MMDB) Close() error`;
1659 - `func Locatable(ip net.IP) bool`.
1660
1661- [ ] **Step 1: Add the dependency and the pinned fixtures (network)**
1662
1663Fetch every fixture and licence file at the commit the approved F1 text
1664names (Task 0 Step 0 resolved it before review), so the files and their
1665recorded provenance cannot disagree (codex plan-R3 3). Run this outside the
1666sandbox if needed:
1667
1668```bash
1669go get github.com/oschwald/maxminddb-golang@v1.13.1
1670rec=docs/superpowers/plans/2026-10-02-nearest-region-copy.json
1671sha=$(jq -r '.entries[] | select(.id == "F1") | .text' "$rec" | grep -oE '[0-9a-f]{40}')
1672[ ${#sha} = 40 ] && [ -z "${sha//[0-9a-f]/}" ] || { echo "F1 must name exactly one commit"; exit 1; }
1673base="https://raw.githubusercontent.com/maxmind/MaxMind-DB/$sha"
1674d=internal/geo/testdata
1675mkdir -p "$d"
1676for f in test-data/GeoIP2-City-Test.mmdb test-data/GeoIP2-Country-Test.mmdb \
1677 test-data/MaxMind-DB-test-ipv4-24.mmdb source-data/GeoIP2-Country-Test.json \
1678 LICENSE-MIT LICENSE-APACHE; do
1679 curl -fsSL -o "$d/$(basename "$f")" "$base/$f" || { echo "missing $f at $sha"; exit 1; }
1680done
1681echo "$sha"
1682```
1683
1684If a licence file has a different name at that commit, list the repository
1685root at `$sha` on GitHub and fetch the MIT and Apache licence files under
1686their real names. Write `internal/geo/testdata/README` as the record's F1
1687text, byte for byte, with nothing substituted:
1688
1689```bash
1690jq -j '.entries[] | select(.id == "F1") | .text' "$rec" > internal/geo/testdata/README
1691```
1692
1693- [ ] **Step 2: Write the failing tests**
1694
1695```go
1696// internal/geo/mmdb_test.go
1697package geo
1698
1699import (
1700 "bytes"
1701 "errors"
1702 "log"
1703 "net"
1704 "os"
1705 "path/filepath"
1706 "testing"
1707)
1708
1709func TestOpenMMDBRefusesNonDatabases(t *testing.T) {
1710 dir := t.TempDir()
1711 missing := filepath.Join(dir, "missing.mmdb")
1712 junk := filepath.Join(dir, "junk.mmdb")
1713 if err := os.WriteFile(junk, []byte("not a maxmind database"), 0o600); err != nil {
1714 t.Fatal(err)
1715 }
1716 city, err := os.ReadFile("testdata/GeoIP2-City-Test.mmdb")
1717 if err != nil {
1718 t.Fatal(err)
1719 }
1720 truncated := filepath.Join(dir, "truncated.mmdb")
1721 if err := os.WriteFile(truncated, city[:len(city)/3], 0o600); err != nil {
1722 t.Fatal(err)
1723 }
1724 for _, p := range []string{missing, junk, truncated} {
1725 if m, err := OpenMMDB(p); err == nil {
1726 m.Close()
1727 t.Errorf("OpenMMDB(%s) succeeded, want an error", filepath.Base(p))
1728 }
1729 }
1730}
1731
1732func TestMMDBCityRecordGivesItsLocation(t *testing.T) {
1733 m, err := OpenMMDB("testdata/GeoIP2-City-Test.mmdb")
1734 if err != nil {
1735 t.Fatal(err)
1736 }
1737 defer m.Close()
1738 p, ok := m.Locate(net.ParseIP("81.2.69.142"))
1739 if !ok || p.Lat < 51 || p.Lat > 52 {
1740 t.Fatalf("81.2.69.142 = (%v, %v), want a London point", p, ok)
1741 }
1742 // Review Focus 2: an IPv4-mapped IPv6 address is the same client.
1743 if mapped, ok := m.Locate(net.ParseIP("::ffff:81.2.69.142")); !ok || mapped != p {
1744 t.Fatalf("::ffff:81.2.69.142 = (%v, %v), want %v", mapped, ok, p)
1745 }
1746}
1747
1748func TestMMDBCountryOnlyRecordGivesTheCountryPoint(t *testing.T) {
1749 m, err := OpenMMDB("testdata/GeoIP2-Country-Test.mmdb")
1750 if err != nil {
1751 t.Fatal(err)
1752 }
1753 defer m.Close()
1754 p, ok := m.Locate(net.ParseIP("81.2.69.142"))
1755 gb, _ := CountryPoint("GB")
1756 if !ok || p != gb {
1757 t.Fatalf("country-only 81.2.69.142 = (%v, %v), want GB's point %v", p, ok, gb)
1758 }
1759}
1760
1761// Review Focus 2 and spec §6.5: a public IPv6 address looked up in an
1762// IPv4-only database is a miss, and the reader's error — which contains
1763// the address — never reaches the log.
1764func TestMMDBLookupErrorsNeverLogTheAddress(t *testing.T) {
1765 m, err := OpenMMDB("testdata/MaxMind-DB-test-ipv4-24.mmdb")
1766 if err != nil {
1767 t.Fatal(err)
1768 }
1769 defer m.Close()
1770 var buf bytes.Buffer
1771 prev := log.Writer()
1772 log.SetOutput(&buf)
1773 defer log.SetOutput(prev)
1774 lookupErrLast = lookupErrLast.AddDate(-1, 0, 0) // un-throttle
1775 if _, ok := m.Locate(net.ParseIP("2001:4860::1")); ok {
1776 t.Fatal("an IPv6 address located in an IPv4-only database")
1777 }
1778 if bytes.Contains(buf.Bytes(), []byte("2001:4860")) {
1779 t.Fatalf("the address leaked into the log: %s", buf.String())
1780 }
1781 if buf.Len() == 0 {
1782 t.Fatal("a lookup failure must be logged (as a fixed category)")
1783 }
1784}
1785
1786// Review Focus 2: non-public addresses never reach the reader — proved
1787// through Locate itself, with a counting lookup in place of the reader.
1788func TestLocateNeverLooksUpNonPublicAddresses(t *testing.T) {
1789 calls := 0
1790 m := &MMDB{lookup: func(net.IP, any) error { calls++; return errors.New("unused") }}
1791 for _, s := range []string{
1792 "127.0.0.1", "::1", "10.1.2.3", "192.168.0.1", "172.16.0.1",
1793 "100.64.0.1", "169.254.1.1", "fe80::1", "fd00::1", "0.0.0.0", "::",
1794 } {
1795 if _, ok := m.Locate(net.ParseIP(s)); ok {
1796 t.Errorf("Locate(%s) answered", s)
1797 }
1798 }
1799 if calls != 0 {
1800 t.Fatalf("the reader was asked %d time(s) about non-public addresses", calls)
1801 }
1802 m.Locate(net.ParseIP("81.2.69.142"))
1803 if calls != 1 {
1804 t.Fatalf("a public address must reach the reader, calls = %d", calls)
1805 }
1806 var nilM *MMDB
1807 if _, ok := nilM.Locate(net.ParseIP("81.2.69.142")); ok {
1808 t.Error("a nil *MMDB located an address")
1809 }
1810}
1811```
1812
1813If 81.2.69.142 is absent from `GeoIP2-Country-Test.mmdb`, choose an address
1814that `testdata/GeoIP2-Country-Test.json` maps to a country, and assert that
1815country's `CountryPoint`.
1816
1817- [ ] **Step 3: Run them to verify they fail**
1818
1819Run: `go test ./internal/geo -run 'MMDB|Locate' -count=1`
1820Expected: FAIL, undefined `OpenMMDB`.
1821
1822- [ ] **Step 4: Implement `mmdb.go`**
1823
1824```go
1825package geo
1826
1827import (
1828 "log"
1829 "net"
1830 "sync"
1831 "time"
1832
1833 "github.com/oschwald/maxminddb-golang"
1834)
1835
1836// Locator turns a client address into a point. A nil Locator, or a nil
1837// *MMDB, locates nothing: the IP step is optional (spec §6.2).
1838type Locator interface {
1839 Locate(ip net.IP) (Point, bool)
1840}
1841
1842// MMDB is an operator-supplied MaxMind-format database
1843// (CONSOLE_GEOIP_DB). lookup is the reader's Lookup, a field so a test can
1844// prove which addresses ever reach it.
1845type MMDB struct {
1846 r *maxminddb.Reader
1847 lookup func(ip net.IP, result any) error
1848}
1849
1850// OpenMMDB opens and verifies the file. An error means "IP step off"; the
1851// console logs it once and starts anyway.
1852func OpenMMDB(path string) (*MMDB, error) {
1853 r, err := maxminddb.Open(path)
1854 if err != nil {
1855 return nil, err
1856 }
1857 if err := r.Verify(); err != nil {
1858 r.Close()
1859 return nil, err
1860 }
1861 return &MMDB{r: r, lookup: r.Lookup}, nil
1862}
1863
1864// Close releases the mapping.
1865func (m *MMDB) Close() error {
1866 if m == nil || m.r == nil {
1867 return nil
1868 }
1869 return m.r.Close()
1870}
1871
1872type mmdbRecord struct {
1873 Location struct {
1874 Latitude *float64 `maxminddb:"latitude"`
1875 Longitude *float64 `maxminddb:"longitude"`
1876 } `maxminddb:"location"`
1877 Country struct {
1878 ISOCode string `maxminddb:"iso_code"`
1879 } `maxminddb:"country"`
1880 RegisteredCountry struct {
1881 ISOCode string `maxminddb:"iso_code"`
1882 } `maxminddb:"registered_country"`
1883}
1884
1885var (
1886 lookupErrMu sync.Mutex
1887 lookupErrLast time.Time
1888)
1889
1890// Locate returns the record's location (City databases), else its
1891// country's point, else its registered country's. A lookup error is a
1892// miss, logged as a FIXED category at most once a minute: the reader's
1893// own error text can contain the address (spec §6.5).
1894func (m *MMDB) Locate(ip net.IP) (Point, bool) {
1895 if m == nil || m.lookup == nil || !Locatable(ip) {
1896 return Point{}, false
1897 }
1898 if v4 := ip.To4(); v4 != nil {
1899 ip = v4
1900 }
1901 var rec mmdbRecord
1902 if err := m.lookup(ip, &rec); err != nil {
1903 lookupErrMu.Lock()
1904 if time.Since(lookupErrLast) > time.Minute {
1905 lookupErrLast = time.Now()
1906 log.Print("geo: a GeoIP lookup failed and was treated as a miss")
1907 }
1908 lookupErrMu.Unlock()
1909 return Point{}, false
1910 }
1911 if rec.Location.Latitude != nil && rec.Location.Longitude != nil {
1912 return Point{*rec.Location.Latitude, *rec.Location.Longitude}, true
1913 }
1914 if p, ok := CountryPoint(rec.Country.ISOCode); ok {
1915 return p, true
1916 }
1917 return CountryPoint(rec.RegisteredCountry.ISOCode)
1918}
1919
1920var cgnat = &net.IPNet{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)}
1921
1922// Locatable is false for addresses that say nothing about where a person
1923// is: loopback, private, link-local, CGNAT and unspecified.
1924func Locatable(ip net.IP) bool {
1925 if ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
1926 ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || cgnat.Contains(ip) {
1927 return false
1928 }
1929 return true
1930}
1931```
1932
1933The log text is O5 from the copy record.
1934
1935- [ ] **Step 5: Run the tests to verify they pass**
1936
1937Run: `go test ./internal/geo -count=1 && go mod tidy && git diff --stat go.mod go.sum`
1938Expected: PASS. `go.mod` gains `github.com/oschwald/maxminddb-golang v1.13.1`
1939and its `golang.org/x/sys` requirement only.
1940
1941- [ ] **Step 6: Cross-compile, then commit**
1942
1943```bash
1944fail=0
1945for t in darwin/arm64 darwin/amd64 linux/arm64 windows/amd64 windows/arm64; do
1946 CGO_ENABLED=0 GOOS=${t%/*} GOARCH=${t#*/} go build ./... || { echo "FAILED $t"; fail=1; }
1947done
1948[ "$fail" = 0 ] || exit 1 # stops this block before any commit below it
1949git add go.mod go.sum internal/geo/mmdb.go internal/geo/mmdb_test.go internal/geo/testdata
1950git commit -m "geo: optional GeoIP locator; lookup errors logged as a fixed category (nearest-region §6.2, §6.5)
1951
1952Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
1953```
1954
1955---
1956
1957### Task 6: Console page copy (C1, C2, C3, C5, C7, C8, C21, C24, from the record)
1958
1959**Files:**
1960- Create: `internal/console/regioncopy.go`
1961- Test: `internal/console/regioncopy_test.go`
1962
1963**Interfaces:**
1964- Consumes: the record's C1, C2, C3, C5, C7, C8, C21 and C24 (Task 0).
1965- Produces:
1966 - `var regionCopy regionCopyStrings`, with fields `PickerLabel`,
1967 `PickerHelp`, `AutoOption`, `Unavailable`, `StartsIn`, `CantStart`,
1968 `AuditCreated` and `Attribution`;
1969 - `func (p page) RegionCopy() regionCopyStrings`.
1970
1971- [ ] **Step 1: Write the failing test**
1972
1973```go
1974// internal/console/regioncopy_test.go
1975package console
1976
1977import (
1978 "fmt"
1979 "reflect"
1980 "strings"
1981 "testing"
1982)
1983
1984func TestRegionCopyHasNoEmDashesAndRendersItsVerbs(t *testing.T) {
1985 v := reflect.ValueOf(regionCopy)
1986 for i := 0; i < v.NumField(); i++ {
1987 s := v.Field(i).String()
1988 if s == "" || strings.Contains(s, "—") {
1989 t.Errorf("%s = %q: empty or has an em dash", v.Type().Field(i).Name, s)
1990 }
1991 }
1992 for _, s := range []string{
1993 fmt.Sprintf(regionCopy.AutoOption, "P"),
1994 fmt.Sprintf(regionCopy.StartsIn, "P"),
1995 fmt.Sprintf(regionCopy.CantStart, "P"),
1996 fmt.Sprintf(regionCopy.AuditCreated, "blog", "P"),
1997 } {
1998 if strings.Contains(s, "%!") {
1999 t.Errorf("bad verb: %q", s)
2000 }
2001 }
2002}
2003```
2004
2005- [ ] **Step 2: Run it to verify it fails**
2006
2007Run: `go test ./internal/console -run TestRegionCopy -count=1`
2008Expected: FAIL, undefined `regionCopy`.
2009
2010- [ ] **Step 3: Write the strings from the record**
2011
2012The values below are the drafts Task 0 submitted. Write each field from its
2013record entry verbatim.
2014
2015```go
2016package console
2017
2018// regionCopy is every console-page string the nearest-region feature
2019// shows (spec §19), as approved in the nearest-region copy record. Verbs
2020// are %s in the order each comment names. Change none of them without a
2021// new copy review.
2022type regionCopyStrings struct {
2023 PickerLabel string // C1
2024 PickerHelp string // C2
2025 AutoOption string // C3: place
2026 Unavailable string // C5
2027 StartsIn string // C7: place
2028 CantStart string // C8: place
2029 AuditCreated string // C21: app name, place
2030 Attribution string // C24 (linked to https://db-ip.com)
2031}
2032
2033var regionCopy = regionCopyStrings{
2034 PickerLabel: "Region",
2035 PickerHelp: "Where this app's instances run. We picked the region nearest to you. Choose another if most of your users are somewhere else.",
2036 AutoOption: "%s (nearest to you)",
2037 Unavailable: "That region can't take new apps right now. Choose another.",
2038 StartsIn: "New instances start in %s, this app's region.",
2039 CantStart: "New instances can't start in %s, this app's region, right now. Pass a region to place one elsewhere.",
2040 AuditCreated: "created app %s in %s",
2041 Attribution: "IP location by DB-IP.",
2042}
2043
2044func (p page) RegionCopy() regionCopyStrings { return regionCopy }
2045```
2046
2047- [ ] **Step 4: Run it to verify it passes**
2048
2049Run: `go test ./internal/console -run TestRegionCopy -count=1`
2050Expected: PASS.
2051
2052- [ ] **Step 5: Commit**
2053
2054```bash
2055git add internal/console/regioncopy.go internal/console/regioncopy_test.go
2056git commit -m "console: region page copy, as approved
2057
2058Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
2059```
2060
2061---
2062
2063### Task 7: Console API messages (C16 to C20, C16b to C19b, C26 to C29, C31, reasons; from the record)
2064
2065**Files:**
2066- Create: `internal/console/regionmsgs.go`
2067- Test: `internal/console/regionmsgs_test.go`
2068
2069**Interfaces:**
2070- Consumes: the record's C16 to C20, C16b to C19b, C26 to C29, C31, and
2071 the four `R-*` entries (Task 0).
2072- Produces:
2073
2074```go
2075func reasonText(reason string) string // absent | not-hosting | no-secrets-key | not-eligible
2076func msgAppRegionUnavailable(code, reason string, can []string) string // C16; C16b when can is empty
2077func msgAppRegionNotAllowed(code string, allowed []string) string // C17; C17b when empty
2078func msgRegionUnknown(code string, choices []string) string // C18; C18b when empty
2079func msgRegionNotArmed(code string, choices []string) string // C19; C19b when empty
2080const msgAppRegionUnreadable // C20
2081func msgReplicaOverlap(code string) string // C26
2082const msgPolicyUnreadable // C27
2083func msgAppRegionUnconfirmed(code string) string // C28
2084func msgPlacementDiscrepancy(host, app, code string) string // C29
2085func msgNoSecretsKey(code string) string // C31
2086```
2087
2088Each list a refusal carries names only what the door would accept (codex
2089plan-R4 2), so it can be empty. An empty list never renders as a list: the
2090`b` variant drops the list sentence.
2091
2092- [ ] **Step 1: Write the failing test**
2093
2094```go
2095// internal/console/regionmsgs_test.go
2096package console
2097
2098import (
2099 "strings"
2100 "testing"
2101)
2102
2103func TestRegionMessagesRender(t *testing.T) {
2104 all := []string{
2105 msgAppRegionUnavailable("uk", "absent", []string{"ie", "au"}),
2106 msgAppRegionNotAllowed("uk", []string{"ie"}),
2107 msgRegionUnknown("zz", []string{"ie", "uk"}),
2108 msgRegionNotArmed("uk", []string{"ie"}),
2109 msgAppRegionUnreadable,
2110 msgReplicaOverlap("uk"),
2111 msgPolicyUnreadable,
2112 msgAppRegionUnconfirmed("uk"),
2113 msgPlacementDiscrepancy("a.go.tito.io", "blog", "uk"),
2114 msgNoSecretsKey("uk"),
2115 // codex plan-R4 2: the empty-list variants, C16b to C19b.
2116 msgAppRegionUnavailable("uk", "absent", nil),
2117 msgAppRegionNotAllowed("uk", nil),
2118 msgRegionUnknown("zz", nil),
2119 msgRegionNotArmed("uk", nil),
2120 }
2121 for _, s := range all {
2122 if s == "" || strings.Contains(s, "—") || strings.Contains(s, "%!") {
2123 t.Errorf("bad message %q", s)
2124 }
2125 }
2126 if !strings.Contains(all[0], reasonText("absent")) || !strings.Contains(all[0], "ie, au") {
2127 t.Errorf("C16 = %q, want the readable reason and the list", all[0])
2128 }
2129 for _, s := range all[len(all)-4:] {
2130 if strings.Contains(s, ": .") || strings.Contains(s, ": ,") {
2131 t.Errorf("an empty list rendered as a list: %q", s)
2132 }
2133 }
2134 seen := map[string]bool{}
2135 for _, r := range []string{"absent", "not-hosting", "no-secrets-key", "not-eligible"} {
2136 txt := reasonText(r)
2137 if txt == r || seen[txt] {
2138 t.Errorf("reasonText(%s) = %q: untranslated or duplicated", r, txt)
2139 }
2140 seen[txt] = true
2141 }
2142}
2143```
2144
2145- [ ] **Step 2: Run it to verify it fails**
2146
2147Run: `go test ./internal/console -run TestRegionMessagesRender -count=1`
2148Expected: FAIL, undefined.
2149
2150- [ ] **Step 3: Write the strings from the record**
2151
2152The drafts are shown. Write each from its record entry verbatim.
2153
2154```go
2155package console
2156
2157import (
2158 "fmt"
2159 "strings"
2160)
2161
2162// The nearest-region feature's API and operator messages (spec §19), as
2163// approved in the nearest-region copy record. Change none of them without
2164// a new copy review.
2165
2166func reasonText(reason string) string {
2167 switch reason {
2168 case "absent":
2169 return "no box runs there now"
2170 case "not-hosting":
2171 return "boxes there can't start instances"
2172 case "no-secrets-key":
2173 return "boxes there can't open this deployment's secrets"
2174 case "not-eligible":
2175 return "boxes there don't take ordinary instances"
2176 }
2177 return reason
2178}
2179
2180// C16, or C16b when no region would pass (codex plan-R4 2: a list names
2181// only what the door takes, so it can be empty).
2182func msgAppRegionUnavailable(code, reason string, can []string) string {
2183 if len(can) == 0 {
2184 return fmt.Sprintf("this app's region %s can't take new instances right now (%s), and no other region this app may use can either. Try again later.",
2185 code, reasonText(reason))
2186 }
2187 return fmt.Sprintf("this app's region %s can't take new instances right now (%s). Pass a region to place this instance elsewhere. Regions that can: %s.",
2188 code, reasonText(reason), strings.Join(can, ", "))
2189}
2190
2191// C17, or C17b when no allowed region would pass.
2192func msgAppRegionNotAllowed(code string, allowed []string) string {
2193 if len(allowed) == 0 {
2194 return fmt.Sprintf("this app's region %s is not one its instances may use, and no region they may use can take new instances right now. Try again later.", code)
2195 }
2196 return fmt.Sprintf("this app's region %s is not one its instances may use. Allowed: %s. Pass a region to choose one.",
2197 code, strings.Join(allowed, ", "))
2198}
2199
2200// C18, or C18b when no region would pass.
2201func msgRegionUnknown(code string, choices []string) string {
2202 if len(choices) == 0 {
2203 return fmt.Sprintf("region %s is not one this platform runs in. Run the command without --region.", code)
2204 }
2205 return fmt.Sprintf("region %s is not one this platform runs in. Choose from: %s.", code, strings.Join(choices, ", "))
2206}
2207
2208// C19, or C19b when no region would pass.
2209func msgRegionNotArmed(code string, choices []string) string {
2210 if len(choices) == 0 {
2211 return fmt.Sprintf("region %s can't take new apps right now. Run the command without --region.", code)
2212 }
2213 return fmt.Sprintf("region %s can't take new apps right now. Choose from: %s.", code, strings.Join(choices, ", "))
2214}
2215
2216// C20
2217const msgAppRegionUnreadable = "could not read this app's region. Try again."
2218
2219// C26
2220func msgReplicaOverlap(code string) string {
2221 return fmt.Sprintf("this app's region %s is also one of this instance's replica placements. Pass a region for the instance, or drop %s from replicas.", code, code)
2222}
2223
2224// C27
2225const msgPolicyUnreadable = "could not read this app's instance policy, so there is no region suggestion. Try again."
2226
2227// C28
2228func msgAppRegionUnconfirmed(code string) string {
2229 return fmt.Sprintf("could not confirm that this app's region %s can take new instances. Try again, or pass a region.", code)
2230}
2231
2232// C29: an operator log line.
2233func msgPlacementDiscrepancy(host, app, code string) string {
2234 return fmt.Sprintf("possible placement discrepancy: %s of %s has no region, but %s's region is %s. It may be a deliberate home override; review it.", host, app, app, code)
2235}
2236
2237// C31 (R9)
2238func msgNoSecretsKey(code string) string {
2239 return fmt.Sprintf("region %s 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.", code)
2240}
2241```
2242
2243- [ ] **Step 4: Run it to verify it passes**
2244
2245Run: `go test ./internal/console -run TestRegionMessagesRender -count=1`
2246Expected: PASS.
2247
2248- [ ] **Step 5: Commit**
2249
2250```bash
2251git add internal/console/regionmsgs.go internal/console/regionmsgs_test.go
2252git commit -m "console: region API messages, as approved
2253
2254Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
2255```
2256
2257---
2258
2259### Task 8: Store columns, rebuild list and accessors
2260
2261**Files:**
2262- Modify: `internal/console/store.go`
2263 - the ALTER list, ending at `:386`;
2264 - `migrateAppsKey`, at `:487-505`;
2265 - `CreateAppIn`, at `:1349`.
2266- Test: `internal/console/store_region_test.go` (create)
2267
2268**Interfaces:**
2269- Produces:
2270
2271```go
2272func (s *Store) CreateAppInRegion(accountID int64, app, createdBy, region, source string, now time.Time) (created bool, err error)
2273func (s *Store) AppRegion(accountID int64, app string) (region, source string, err error)
2274type nonHomeAppRegion struct{ Sqid, App, Region string; CreatedAt time.Time }
2275func (s *Store) NonHomeAppRegions() ([]nonHomeAppRegion, error)
2276```
2277
2278- [ ] **Step 1: Write the failing tests**
2279
2280```go
2281// internal/console/store_region_test.go
2282package console
2283
2284import (
2285 "database/sql"
2286 "path/filepath"
2287 "testing"
2288 "time"
2289
2290 _ "modernc.org/sqlite"
2291)
2292
2293func TestAppRegionRoundTripAndDefaults(t *testing.T) {
2294 s := openTestStore(t)
2295 now := time.Now()
2296 if ok, err := s.CreateAppInRegion(OpsAccountID, "blog", "p@k.dev", "uk", "timezone", now); err != nil || !ok {
2297 t.Fatalf("CreateAppInRegion = %v %v", ok, err)
2298 }
2299 if r, src, err := s.AppRegion(OpsAccountID, "blog"); err != nil || r != "uk" || src != "timezone" {
2300 t.Fatalf("AppRegion = %q %q %v", r, src, err)
2301 }
2302 if ok, err := s.CreateAppIn(OpsAccountID, "old", "p@k.dev", now); err != nil || !ok {
2303 t.Fatalf("CreateAppIn = %v %v", ok, err)
2304 }
2305 if r, src, _ := s.AppRegion(OpsAccountID, "old"); r != "" || src != "" {
2306 t.Fatalf("CreateAppIn row = %q %q, want the home and no source", r, src)
2307 }
2308 if r, src, err := s.AppRegion(OpsAccountID, "missing"); err != nil || r != "" || src != "" {
2309 t.Fatalf("missing row = %q %q %v", r, src, err)
2310 }
2311 // A lost claim race writes nothing.
2312 if ok, _ := s.CreateAppInRegion(OpsAccountID, "blog", "x@k.dev", "au", "ip", now); ok {
2313 t.Fatal("a second claim of the same name succeeded")
2314 }
2315 if r, _, _ := s.AppRegion(OpsAccountID, "blog"); r != "uk" {
2316 t.Fatalf("a lost claim changed the region to %q", r)
2317 }
2318}
2319
2320// The fixture runs NewServer, whose backfill creates the ops account row
2321// (sqid "bab") that NonHomeAppRegions joins on.
2322func TestNonHomeAppRegions(t *testing.T) {
2323 fx := newConsoleFixture(t)
2324 now := time.Now()
2325 if _, err := fx.st.CreateAppInRegion(OpsAccountID, "blog", "p@k.dev", "uk", "chosen", now); err != nil {
2326 t.Fatal(err)
2327 }
2328 if _, err := fx.st.CreateAppInRegion(OpsAccountID, "home", "p@k.dev", "", "default", now); err != nil {
2329 t.Fatal(err)
2330 }
2331 rows, err := fx.st.NonHomeAppRegions()
2332 if err != nil || len(rows) != 1 || rows[0].App != "blog" || rows[0].Region != "uk" || rows[0].Sqid != "bab" {
2333 t.Fatalf("NonHomeAppRegions = %+v %v", rows, err)
2334 }
2335}
2336
2337// spec §5.2: a database from BEFORE migrateAppsKey whose rows already
2338// carry an app region keeps it through the rebuild, a reopen, a rename and
2339// a move to another account. An empty value would not prove the copy.
2340func TestRegionValuesSurviveRebuildRenameAndMove(t *testing.T) {
2341 path := filepath.Join(t.TempDir(), "console.db")
2342 db, err := sql.Open("sqlite", path)
2343 if err != nil {
2344 t.Fatal(err)
2345 }
2346 if _, err := db.Exec(`CREATE TABLE apps (
2347 app TEXT PRIMARY KEY, created_by TEXT NOT NULL, created_at INTEGER NOT NULL,
2348 account_id INTEGER NOT NULL DEFAULT 1, deleted_at INTEGER NOT NULL DEFAULT 0,
2349 placement TEXT NOT NULL DEFAULT '', control_plane INTEGER NOT NULL DEFAULT 0,
2350 build_probe TEXT NOT NULL DEFAULT '', region TEXT NOT NULL DEFAULT '',
2351 region_source TEXT NOT NULL DEFAULT '');
2352INSERT INTO apps (app, created_by, created_at, region, region_source) VALUES ('legacy', 'p@k.dev', 1, 'uk', 'timezone');`); err != nil {
2353 t.Fatal(err)
2354 }
2355 db.Close()
2356 for i := 0; i < 2; i++ { // the rebuild, then a reopen of the rebuilt table
2357 s, err := Open(path)
2358 if err != nil {
2359 t.Fatalf("open %d: %v", i, err)
2360 }
2361 if r, src, err := s.AppRegion(OpsAccountID, "legacy"); err != nil || r != "uk" || src != "timezone" {
2362 t.Fatalf("open %d: legacy = %q %q %v, want uk/timezone carried", i, r, src, err)
2363 }
2364 s.Close()
2365 }
2366 s, err := Open(path)
2367 if err != nil {
2368 t.Fatal(err)
2369 }
2370 defer s.Close()
2371 // Open migrates but does not backfill accounts (NewServer does), and
2372 // rename and move both resolve the app's account row: seed the ops
2373 // account the legacy row belongs to, and the move's destination.
2374 if _, err := s.db.Exec(`INSERT OR IGNORE INTO accounts (id, sqid, name, created_at) VALUES (?, 'bab', 'Ops', 0)`, OpsAccountID); err != nil {
2375 t.Fatal(err)
2376 }
2377 if err := s.RenameAppIn(OpsAccountID, "legacy", "renamed"); err != nil {
2378 t.Fatal(err)
2379 }
2380 if r, src, _ := s.AppRegion(OpsAccountID, "renamed"); r != "uk" || src != "timezone" {
2381 t.Fatalf("after rename = %q %q", r, src)
2382 }
2383 const other = 2001
2384 if _, err := s.db.Exec(`INSERT INTO accounts (id, sqid, name, created_at) VALUES (?, 'zzz', 'Other', 0)`, other); err != nil {
2385 t.Fatal(err)
2386 }
2387 if err := s.MoveApp("renamed", OpsAccountID, other); err != nil {
2388 t.Fatal(err)
2389 }
2390 if r, src, _ := s.AppRegion(other, "renamed"); r != "uk" || src != "timezone" {
2391 t.Fatalf("after move = %q %q", r, src)
2392 }
2393}
2394```
2395
2396- [ ] **Step 2: Run them to verify they fail**
2397
2398Run: `go test ./internal/console -run 'TestAppRegion|TestNonHomeAppRegions|TestRegionValuesSurvive' -count=1`
2399Expected: FAIL, undefined `CreateAppInRegion`.
2400
2401- [ ] **Step 3: Add the columns to the ALTER list**
2402
2403Directly after the `build_probe` ALTER:
2404
2405```go
2406 // App region (nearest-region spec §5.1): the placement code new
2407 // instances default to, in the RECORD vocabulary — "" is the
2408 // deployment's home, which is also what every existing row reads, so
2409 // the column default IS the backfill (§5.4). region_source is a label
2410 // for people and logs: chosen | timezone | ip | default | "" (the row
2411 // predates the feature). No timezone, IP or coordinate is stored.
2412 `ALTER TABLE apps ADD COLUMN region TEXT NOT NULL DEFAULT '';`,
2413 `ALTER TABLE apps ADD COLUMN region_source TEXT NOT NULL DEFAULT '';`,
2414```
2415
2416- [ ] **Step 4: Carry them through the rebuild**
2417
2418In `migrateAppsKey`, replace the DDL and the copy:
2419
2420```go
2421 `CREATE TABLE apps_new (
2422 app TEXT NOT NULL,
2423 account_id INTEGER NOT NULL DEFAULT 1,
2424 created_by TEXT NOT NULL,
2425 created_at INTEGER NOT NULL,
2426 deleted_at INTEGER NOT NULL DEFAULT 0,
2427 placement TEXT NOT NULL DEFAULT '',
2428 control_plane INTEGER NOT NULL DEFAULT 0,
2429 build_probe TEXT NOT NULL DEFAULT '',
2430 region TEXT NOT NULL DEFAULT '',
2431 region_source TEXT NOT NULL DEFAULT '',
2432 PRIMARY KEY (account_id, app)
2433);`,
2434 // The ALTERs above run before migrateAppsKey in migrate(), so every
2435 // column below already exists on the old table when this copies it;
2436 // a row already carrying an app region must not lose it
2437 // (nearest-region spec §5.2).
2438 `INSERT INTO apps_new (app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, region, region_source)
2439 SELECT app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, region, region_source FROM apps;`,
2440```
2441
2442- [ ] **Step 5: Add the accessors**
2443
2444```go
2445func (s *Store) CreateAppIn(accountID int64, app, createdBy string, now time.Time) (created bool, err error) {
2446 return s.CreateAppInRegion(accountID, app, createdBy, "", "", now)
2447}
2448
2449// CreateAppInRegion claims an app name with its region in the SAME insert:
2450// an app region has no repair verb, so a separate best-effort write that
2451// failed would leave the app at the home for good, silently (spec §5.3).
2452func (s *Store) CreateAppInRegion(accountID int64, app, createdBy, region, source string, now time.Time) (created bool, err error) {
2453 res, err := s.db.Exec(`INSERT INTO apps (app, account_id, created_by, created_at, region, region_source)
2454VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(account_id, app) DO NOTHING`,
2455 app, accountID, createdBy, now.Unix(), region, source)
2456 if err != nil {
2457 return false, err
2458 }
2459 n, err := res.RowsAffected()
2460 return n > 0, err
2461}
2462
2463// AppRegion reads an app's region and its source label. A missing row
2464// reads as the home with no source.
2465func (s *Store) AppRegion(accountID int64, app string) (region, source string, err error) {
2466 err = s.db.QueryRow(`SELECT region, region_source FROM apps WHERE account_id = ? AND app = ?`, accountID, app).
2467 Scan(&region, &source)
2468 if errors.Is(err, sql.ErrNoRows) {
2469 return "", "", nil
2470 }
2471 return region, source, err
2472}
2473
2474// nonHomeAppRegion is one live app holding a non-home region, for the
2475// roll-forward discrepancy log (spec §5.7).
2476type nonHomeAppRegion struct {
2477 Sqid, App, Region string
2478 CreatedAt time.Time
2479}
2480
2481func (s *Store) NonHomeAppRegions() ([]nonHomeAppRegion, error) {
2482 rows, err := s.db.Query(`SELECT a.sqid, p.app, p.region, p.created_at FROM apps p
2483JOIN accounts a ON a.id = p.account_id
2484WHERE p.region != '' AND p.deleted_at = 0 ORDER BY a.sqid, p.app`)
2485 if err != nil {
2486 return nil, err
2487 }
2488 defer rows.Close()
2489 var out []nonHomeAppRegion
2490 for rows.Next() {
2491 var r nonHomeAppRegion
2492 var created int64
2493 if err := rows.Scan(&r.Sqid, &r.App, &r.Region, &created); err != nil {
2494 return nil, err
2495 }
2496 r.CreatedAt = time.Unix(created, 0)
2497 out = append(out, r)
2498 }
2499 return out, rows.Err()
2500}
2501```
2502
2503- [ ] **Step 6: Run the tests to verify they pass**
2504
2505Run: `go test ./internal/console -count=1`
2506Expected: PASS, the whole package.
2507
2508- [ ] **Step 7: Commit**
2509
2510```bash
2511git add internal/console/store.go internal/console/store_region_test.go
2512git commit -m "console store: app region columns carried through the apps-key rebuild (nearest-region §5)
2513
2514Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
2515```
2516
2517---
2518
2519### Task 9: Console config, the switch, the expected secrets fingerprint and the test seams
2520
2521**Files:**
2522- Create: `internal/console/regionconfig.go`, `internal/console/regionconfig_test.go`
2523- Modify: `internal/console/server.go`
2524 - `Config`, near `HomeRegion` at `:477-484`;
2525 - the `Server` struct;
2526 - `NewServer`, whose last line before `return` calls `logRegionConfig`.
2527- Modify: `cmd/console/main.go:278`
2528- Test: `cmd/console/region_test.go` (create, `package main`)
2529
2530**Interfaces:**
2531- Consumes: `geo.Locator` and `geo.OpenMMDB` (Task 5),
2532 `appconfig.PublicFingerprint` (Task 1b), and the record's O1 to O4.
2533- Produces:
2534 - `Config.AppRegions bool` and `Config.GeoIP geo.Locator`;
2535 - `Server.appRegionOf func(accountID int64, app string) (string, string, error)`,
2536 a test seam, nil in production;
2537 - `Server.beforeClaimInsert func()`, a test seam, nil in production;
2538 - the test helper `mustTestSecretsKey() (pubB64, fp string)`, in
2539 `regionconfig_test.go`, used by every later R9 test;
2540 - these methods and helpers:
2541
2542```go
2543func (s *Server) appRegionsOn() bool
2544func (s *Server) expectedSecretsFP() string // "" = cannot judge (R9)
2545func (s *Server) appRegion(accountID int64, app string) (string, string, error)
2546func logRegionConfig(cfg Config)
2547func openGeoIP(path string) geo.Locator // cmd/console
2548func appRegionsSwitch(v string) bool // cmd/console
2549```
2550
2551- [ ] **Step 1: Write the failing tests**
2552
2553```go
2554// internal/console/regionconfig_test.go
2555package console
2556
2557import (
2558 "bytes"
2559 "crypto/ecdh"
2560 "encoding/base64"
2561 "log"
2562 "strings"
2563 "testing"
2564
2565 "github.com/carlosframework/platform/internal/appconfig"
2566)
2567
2568// mustTestSecretsKey is the fixed P-256 key every R9 test in this package
2569// uses: its public half as CARLOS_SECRETS_PUBKEY carries it, and the
2570// fingerprint a box holding it publishes.
2571func mustTestSecretsKey() (pubB64, fp string) {
2572 priv, err := ecdh.P256().NewPrivateKey(bytes.Repeat([]byte{1}, 32))
2573 if err != nil {
2574 panic(err)
2575 }
2576 return base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes()), appconfig.KeyFingerprint(priv.PublicKey())
2577}
2578
2579func TestAppRegionsOnNeedsANamedHome(t *testing.T) {
2580 fx := newConsoleFixture(t)
2581 for _, c := range []struct {
2582 on bool
2583 home string
2584 want bool
2585 }{{false, "ie", false}, {true, "", false}, {true, "ie", true}} {
2586 fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion = c.on, c.home
2587 if got := fx.srv.appRegionsOn(); got != c.want {
2588 t.Errorf("AppRegions=%v HomeRegion=%q: appRegionsOn = %v, want %v", c.on, c.home, got, c.want)
2589 }
2590 }
2591}
2592
2593func TestExpectedSecretsFP(t *testing.T) {
2594 fx := newConsoleFixture(t)
2595 pub, fp := mustTestSecretsKey()
2596 fx.srv.cfg.SecretsPub = pub
2597 if got := fx.srv.expectedSecretsFP(); got != fp {
2598 t.Fatalf("expectedSecretsFP = %q, want %q", got, fp)
2599 }
2600 fx.srv.cfg.SecretsPub = ""
2601 if got := fx.srv.expectedSecretsFP(); got != "" {
2602 t.Fatalf("no CARLOS_SECRETS_PUBKEY = %q, want empty (cannot judge)", got)
2603 }
2604}
2605
2606func TestLogRegionConfig(t *testing.T) {
2607 var buf bytes.Buffer
2608 prev := log.Writer()
2609 log.SetOutput(&buf)
2610 defer log.SetOutput(prev)
2611 logRegionConfig(Config{AppRegions: true})
2612 out := buf.String()
2613 if !strings.Contains(out, "CONSOLE_HOME_REGION is unset") || !strings.Contains(out, "CARLOS_SECRETS_PUBKEY is unset") {
2614 t.Fatalf("missing startup lines: %s", out)
2615 }
2616 // Switch off: the guess endpoint still answers, so a missing secrets
2617 // key is still worth saying (codex plan-R2 8); an unnamed home is not.
2618 buf.Reset()
2619 logRegionConfig(Config{})
2620 if out := buf.String(); !strings.Contains(out, "CARLOS_SECRETS_PUBKEY is unset") || strings.Contains(out, "CONSOLE_HOME_REGION") {
2621 t.Fatalf("switch off logged %q, want only the secrets-key line", out)
2622 }
2623 buf.Reset()
2624 pub, _ := mustTestSecretsKey()
2625 logRegionConfig(Config{AppRegions: true, HomeRegion: "ie", SecretsPub: pub})
2626 if buf.Len() != 0 {
2627 t.Fatalf("a complete configuration logged: %s", buf.String())
2628 }
2629}
2630```
2631
2632The two `strings.Contains` fragments are substrings of O1 and O2. Use
2633fragments of the approved text if review changed them.
2634
2635Create `cmd/console/region_test.go`. It must be `package main`:
2636`main_test.go` is the external `package main_test` and cannot reach the
2637unexported helpers.
2638
2639```go
2640package main
2641
2642import (
2643 "path/filepath"
2644 "testing"
2645)
2646
2647func TestOpenGeoIPIsOptionalAndNeverFatal(t *testing.T) {
2648 if openGeoIP("") != nil {
2649 t.Fatal("unset CONSOLE_GEOIP_DB must mean no locator")
2650 }
2651 if openGeoIP(filepath.Join(t.TempDir(), "missing.mmdb")) != nil {
2652 t.Fatal("an unopenable file must mean no locator, not a crash")
2653 }
2654}
2655
2656func TestAppRegionsSwitchParses(t *testing.T) {
2657 for v, want := range map[string]bool{"on": true, "ON": true, " on ": true, "": false, "off": false, "true": false} {
2658 if got := appRegionsSwitch(v); got != want {
2659 t.Errorf("appRegionsSwitch(%q) = %v, want %v", v, got, want)
2660 }
2661 }
2662}
2663```
2664
2665- [ ] **Step 2: Run them to verify they fail**
2666
2667Run: `go test ./internal/console -run 'TestAppRegionsOn|TestExpectedSecretsFP|TestLogRegionConfig' -count=1; go test ./cmd/console -run 'OpenGeoIP|AppRegionsSwitch' -count=1`
2668Expected: FAIL, undefined.
2669
2670- [ ] **Step 3: Add the Config fields and the Server seams**
2671
2672In `Config`, after `HomeRegion`:
2673
2674```go
2675 // AppRegions is CONSOLE_APP_REGIONS=on (nearest-region spec §5.6). Off
2676 // never stops the create door's fill for rows already holding a region.
2677 AppRegions bool
2678 // GeoIP is the optional CONSOLE_GEOIP_DB locator; nil = IP step off.
2679 GeoIP geo.Locator
2680```
2681
2682In the `Server` struct:
2683
2684```go
2685 // appRegionOf and beforeClaimInsert are TEST SEAMS (nil in
2686 // production): a store failure on the app-region read (C20), and a
2687 // competing insert landing after the region was resolved.
2688 appRegionOf func(accountID int64, app string) (string, string, error)
2689 beforeClaimInsert func()
2690```
2691
2692Import `"github.com/carlosframework/platform/internal/geo"`.
2693
2694- [ ] **Step 4: Implement `regionconfig.go`**
2695
2696```go
2697package console
2698
2699import (
2700 "log"
2701
2702 "github.com/carlosframework/platform/internal/appconfig"
2703)
2704
2705// appRegionsOn is spec §5.6: the switch AND a named home.
2706func (s *Server) appRegionsOn() bool {
2707 return s.cfg.AppRegions && s.cfg.HomeRegion != ""
2708}
2709
2710// expectedSecretsFP is the fingerprint a box must publish to receive
2711// placed instances (spec R9): the console's own CARLOS_SECRETS_PUBKEY,
2712// the key the CLI seals config to. "" means the console cannot judge, and
2713// then no coded region is eligible for automatic placement.
2714func (s *Server) expectedSecretsFP() string {
2715 if s.cfg.SecretsPub == "" {
2716 return ""
2717 }
2718 fp, err := appconfig.PublicFingerprint(s.cfg.SecretsPub)
2719 if err != nil {
2720 return ""
2721 }
2722 return fp
2723}
2724
2725// appRegion reads the app's stored region through the test seam when one
2726// is set.
2727func (s *Server) appRegion(accountID int64, app string) (string, string, error) {
2728 if s.appRegionOf != nil {
2729 return s.appRegionOf(accountID, app)
2730 }
2731 return s.store.AppRegion(accountID, app)
2732}
2733
2734// logRegionConfig names, once at startup, each configuration that leaves
2735// part of the feature off. The secrets-key line does not depend on the
2736// switch: the guess endpoint answers with the switch off too (R9).
2737func logRegionConfig(cfg Config) {
2738 if cfg.AppRegions && cfg.HomeRegion == "" {
2739 log.Print("console: CONSOLE_APP_REGIONS=on but CONSOLE_HOME_REGION is unset, so app regions stay off until the home is named")
2740 }
2741 if cfg.SecretsPub == "" {
2742 log.Print("console: CARLOS_SECRETS_PUBKEY is unset, so no region outside the home can be chosen automatically")
2743 }
2744}
2745```
2746
2747The two log texts are O1 and O2 from the record. In `NewServer`, call
2748`logRegionConfig(cfg)` immediately before its successful `return`.
2749
2750- [ ] **Step 5: Wire the environment in `cmd/console/main.go`**
2751
2752Beside `HomeRegion:`:
2753
2754```go
2755 AppRegions: appRegionsSwitch(os.Getenv("CONSOLE_APP_REGIONS")),
2756 GeoIP: openGeoIP(strings.TrimSpace(os.Getenv("CONSOLE_GEOIP_DB"))),
2757```
2758
2759Near `envOr`:
2760
2761```go
2762// appRegionsSwitch reads CONSOLE_APP_REGIONS: exactly "on" (any case,
2763// trimmed) turns app regions on.
2764func appRegionsSwitch(v string) bool { return strings.EqualFold(strings.TrimSpace(v), "on") }
2765
2766// openGeoIP opens the optional CONSOLE_GEOIP_DB. Unset, unopenable or not
2767// an mmdb means the IP step is off — logged once, never fatal. Returns an
2768// untyped nil on every failure, never a nil *geo.MMDB in the interface.
2769func openGeoIP(path string) geo.Locator {
2770 if path == "" {
2771 return nil
2772 }
2773 m, err := geo.OpenMMDB(path)
2774 if err != nil {
2775 log.Printf("console: CONSOLE_GEOIP_DB %s is unusable, so the IP step of region guesses is off: %v", path, err)
2776 return nil
2777 }
2778 log.Printf("console: GeoIP database loaded from %s", path)
2779 return m
2780}
2781```
2782
2783The two log texts are O3 and O4. The open error describes the file, never a
2784client.
2785
2786- [ ] **Step 6: Run the tests to verify they pass**
2787
2788Run: `go test ./internal/console -run 'TestAppRegionsOn|TestExpectedSecretsFP|TestLogRegionConfig' -count=1 && go test ./cmd/console -count=1`
2789Expected: PASS.
2790
2791- [ ] **Step 7: Commit**
2792
2793```bash
2794git add internal/console/server.go internal/console/regionconfig.go internal/console/regionconfig_test.go cmd/console/main.go cmd/console/region_test.go
2795git commit -m "console: app-regions switch, expected secrets fingerprint, GeoIP, test seams (nearest-region §5.6, §6.2, R9)
2796
2797Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
2798```
2799
2800---
2801
2802### Task 10: Strict edges read, candidates, eligibility (incl. R9) and policy
2803
2804**Files:**
2805- Modify: `internal/console/instancesplacement.go`
2806 - `edgesFilePlacementView`, at `:35-55`;
2807 - beside `readEdgesFiles`, at `:83`.
2808- Create: `internal/console/regioncandidates.go`
2809- Test: `internal/console/regioncandidates_test.go`
2810
2811**Interfaces:**
2812- Consumes:
2813 - `provision.ValidRegion` and `provision.GetManifest`;
2814 - `s.appPlacement` (`fleets.go:479`) and `openRegistry`;
2815 - `instanceRegionAllowed` (`apiinstances.go:1797`);
2816 - `geo.LookupAWSRegion` (Task 4);
2817 - `s.expectedSecretsFP` (Task 9);
2818 - the record's O6 and O7.
2819- Produces:
2820
2821```go
2822type edgesSnapshot struct {
2823 Files []edgesFilePlacementView
2824 Complete bool // false when any listed file could not be read or parsed
2825}
2826func readEdgesSnapshot(ctx context.Context, s release.Store) (edgesSnapshot, error) // error only when the LISTING fails
2827type regionCandidate struct {
2828 Code string // record vocabulary; "" is the home
2829 Home bool
2830 AWSRegion string
2831 Reachable, Hosting, KeyOK, Eligible bool
2832 NoKeyProven bool // R9: current writers only, and none holds the key
2833}
2834func regionCandidates(snap edgesSnapshot, homeName, expectedFP string) []regionCandidate
2835func (s *Server) regionCands(snap edgesSnapshot) []regionCandidate
2836func (s *Server) candsOrHome(ctx context.Context) []regionCandidate // a fresh snapshot, or the home alone
2837func homeOnlyCandidates() []regionCandidate
2838func (c regionCandidate) unavailableReason() string // "", absent, not-hosting, no-secrets-key, not-eligible
2839func findCandidate(cands []regionCandidate, code string) (regionCandidate, bool)
2840func eligibleCount(cands []regionCandidate) int
2841type policyState int // policyAbsent, policyKnown, policyUnreadable
2842type regionPolicy struct{ State policyState; M *provision.Manifest; Placed string }
2843func (s *Server) appPolicy(ctx context.Context, acct *Account, app string) regionPolicy
2844func (p regionPolicy) pooled() bool
2845func (p regionPolicy) allows(code string) bool
2846```
2847
2848Test helpers produced for later console tasks, in
2849`regioncandidates_test.go`:
2850
2851- `testKeyPub` and `testKeyFP`, from Task 9's `mustTestSecretsKey`;
2852- `edgeJSON`;
2853- the fixture edges `homeBox`, `ukEligible`, `ukNoKey`, `ukWrongKey`,
2854 `ukOldWriter`, `auPoolOnly`, `usNoInbound`, `nzNoHosting`, `jpNoEIP` and
2855 `fleetBoxFile`;
2856- `splitCapability`, the same-region box pairs no single box of which is
2857 eligible;
2858- `setEdges`, which creates or replaces, unlike `putEdges`;
2859- `failingList` and `captureLog`.
2860
2861- [ ] **Step 1: Write the failing tests and the shared helpers**
2862
2863```go
2864// internal/console/regioncandidates_test.go
2865package console
2866
2867import (
2868 "bytes"
2869 "context"
2870 "encoding/json"
2871 "errors"
2872 "io"
2873 "log"
2874 "os"
2875 "strings"
2876 "testing"
2877
2878 "github.com/carlosframework/platform/internal/provision"
2879 "github.com/carlosframework/platform/internal/release"
2880)
2881
2882// mustTestSecretsKey is Task 9's (regionconfig_test.go).
2883var testKeyPub, testKeyFP = mustTestSecretsKey()
2884
2885func bp(b bool) *bool { return &b }
2886
2887type edgeOpts struct {
2888 hib, shared, inbound *bool // nil = the writer predates the field
2889 fp string
2890}
2891
2892var eligibleBox = edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true), fp: testKeyFP}
2893
2894// edgeJSON writes one box's edges file the way internal/host does.
2895func edgeJSON(box, eip, awsRegion, placement string, o edgeOpts) string {
2896 m := map[string]any{"v": 1, "box": box, "eip": eip, "region": awsRegion, "hosts": map[string]any{}}
2897 if placement != "" {
2898 m["placement"] = placement
2899 }
2900 if o.hib != nil {
2901 m["hibernation_configured"] = *o.hib
2902 }
2903 if o.shared != nil {
2904 m["serves_shared_pool"] = *o.shared
2905 }
2906 if o.inbound != nil {
2907 m["accepts_inbound"] = *o.inbound
2908 }
2909 if o.fp != "" {
2910 m["secrets_key_fp"] = o.fp
2911 }
2912 b, _ := json.Marshal(m)
2913 return string(b)
2914}
2915
2916var (
2917 homeBox = edgeJSON("ie-box", "5.6.7.8", "eu-west-1", "", eligibleBox)
2918 ukEligible = edgeJSON("uk-box", "1.2.3.4", "eu-west-2", "uk", eligibleBox)
2919 ukNoKey = edgeJSON("uk-box", "1.2.3.4", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)})
2920 ukWrongKey = edgeJSON("uk-box", "1.2.3.4", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true), fp: "sha256:" + strings.Repeat("0", 64)})
2921 ukOldWriter = edgeJSON("uk-box", "1.2.3.4", "eu-west-2", "uk", edgeOpts{})
2922 auPoolOnly = edgeJSON("au-box", "9.9.9.9", "ap-southeast-2", "au", edgeOpts{hib: bp(true), shared: bp(false), inbound: bp(true), fp: testKeyFP})
2923 usNoInbound = edgeJSON("us-box", "8.8.4.4", "us-east-1", "us", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(false), fp: testKeyFP})
2924 nzNoHosting = edgeJSON("nz-box", "6.6.6.6", "ap-southeast-6", "nz", edgeOpts{hib: bp(false), shared: bp(true), inbound: bp(true), fp: testKeyFP})
2925 jpNoEIP = edgeJSON("jp-box", "", "ap-northeast-1", "jp", eligibleBox)
2926 fleetBoxFile = edgeJSON("fl-box", "4.4.4.4", "eu-west-2", "fleet/acme", eligibleBox)
2927)
2928
2929// setEdges creates OR replaces edges files. putEdges is create-only (an
2930// empty ifMatch on DirStore and S3), so it cannot change a box's file.
2931func setEdges(t *testing.T, fx *consoleFixture, files map[string]string) {
2932 t.Helper()
2933 ctx := context.Background()
2934 for name, body := range files {
2935 key := "control/edges/" + name + ".json"
2936 etag := ""
2937 if _, cur, err := fx.rel.Get(ctx, key); err == nil {
2938 etag = cur
2939 } else if !errors.Is(err, os.ErrNotExist) {
2940 t.Fatal(err)
2941 }
2942 if _, err := fx.rel.Put(ctx, key, []byte(body), etag); err != nil {
2943 t.Fatalf("set %s: %v", key, err)
2944 }
2945 }
2946}
2947
2948type failingList struct{ release.Store }
2949
2950func (failingList) List(context.Context, string) ([]string, error) {
2951 return nil, errors.New("bucket unreachable")
2952}
2953
2954func captureLog(w io.Writer) (restore func()) {
2955 prev, flags := log.Writer(), log.Flags()
2956 log.SetOutput(w)
2957 return func() { log.SetOutput(prev); log.SetFlags(flags) }
2958}
2959
2960// views is a COMPLETE snapshot of the given edges-file bodies.
2961func views(t *testing.T, bodies ...string) edgesSnapshot {
2962 t.Helper()
2963 snap := edgesSnapshot{Complete: true}
2964 for _, b := range bodies {
2965 var f edgesFilePlacementView
2966 if err := json.Unmarshal([]byte(b), &f); err != nil {
2967 t.Fatal(err)
2968 }
2969 snap.Files = append(snap.Files, f)
2970 }
2971 return snap
2972}
2973
2974func TestRegionCandidatesNeedPositiveEligibility(t *testing.T) {
2975 ca := edgeJSON("ca-box", "7.7.7.7", "ca-central-1", "ca", edgeOpts{})
2976 cands := regionCandidates(views(t, homeBox, ukEligible, auPoolOnly, usNoInbound, ca, nzNoHosting, jpNoEIP, fleetBoxFile), "ie", testKeyFP)
2977 want := map[string]string{"": "", "uk": "", "au": "not-eligible", "us": "not-eligible",
2978 "ca": "no-secrets-key", "nz": "not-hosting", "jp": "absent"}
2979 if len(cands) != len(want) {
2980 t.Fatalf("candidates = %+v, want exactly %v (no fleet code)", cands, want)
2981 }
2982 for code, reason := range want {
2983 c, ok := findCandidate(cands, code)
2984 if !ok {
2985 t.Fatalf("no candidate %q", code)
2986 }
2987 if got := c.unavailableReason(); got != reason {
2988 t.Errorf("%q: reason %q, want %q", code, got, reason)
2989 }
2990 }
2991 if h, _ := findCandidate(cands, ""); !h.Home || h.AWSRegion != "eu-west-1" || !h.Eligible {
2992 t.Errorf("home = %+v", h)
2993 }
2994}
2995
2996// R9: a box must hold the deployment's secrets key to receive placed
2997// instances, and only a CURRENT writer can prove it does not.
2998func TestRegionCandidatesSecretsKey(t *testing.T) {
2999 cases := []struct {
3000 name string
3001 body string
3002 expected string
3003 reason string
3004 noKeyProven bool
3005 }{
3006 {"matching key", ukEligible, testKeyFP, "", false},
3007 {"no key, current writer", ukNoKey, testKeyFP, "no-secrets-key", true},
3008 {"wrong key, current writer", ukWrongKey, testKeyFP, "no-secrets-key", true},
3009 {"old writer: unknown", ukOldWriter, testKeyFP, "no-secrets-key", false},
3010 {"console cannot judge", ukEligible, "", "no-secrets-key", false},
3011 }
3012 for _, c := range cases {
3013 cand, _ := findCandidate(regionCandidates(views(t, homeBox, c.body), "ie", c.expected), "uk")
3014 if got := cand.unavailableReason(); got != c.reason || cand.NoKeyProven != c.noKeyProven {
3015 t.Errorf("%s: reason %q proven %v, want %q %v", c.name, got, cand.NoKeyProven, c.reason, c.noKeyProven)
3016 }
3017 }
3018 // One box with the key and one without: the region is not PROVEN
3019 // keyless, and is eligible through the box that holds it.
3020 ukKeyless2 := edgeJSON("uk-box-2", "1.2.3.5", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)})
3021 cand, _ := findCandidate(regionCandidates(views(t, homeBox, ukEligible, ukKeyless2), "ie", testKeyFP), "uk")
3022 if !cand.Eligible || cand.NoKeyProven {
3023 t.Errorf("mixed region = %+v", cand)
3024 }
3025}
3026
3027// codex plan-R2 1: a negative proof needs EVERY box. With one file
3028// unreadable or malformed, a readable keyless box proves nothing.
3029func TestNoKeyProvenNeedsACompleteRead(t *testing.T) {
3030 incomplete := views(t, homeBox, ukNoKey)
3031 incomplete.Complete = false
3032 if c, _ := findCandidate(regionCandidates(incomplete, "ie", testKeyFP), "uk"); c.NoKeyProven {
3033 t.Fatal("an incomplete read proved a region keyless")
3034 }
3035 ctx := context.Background()
3036 for name, extra := range map[string]string{"malformed": "{not json", "unversioned": `{"box":"uk-box-2"}`} {
3037 s := release.NewDirStore(t.TempDir())
3038 for key, body := range map[string]string{"ie-box": homeBox, "uk-box": ukNoKey, "uk-box-2": extra} {
3039 if _, err := s.Put(ctx, "control/edges/"+key+".json", []byte(body), ""); err != nil {
3040 t.Fatal(err)
3041 }
3042 }
3043 snap, err := readEdgesSnapshot(ctx, s)
3044 if err != nil || snap.Complete {
3045 t.Fatalf("%s: snapshot complete=%v err=%v, want incomplete", name, snap.Complete, err)
3046 }
3047 if c, _ := findCandidate(regionCandidates(snap, "ie", testKeyFP), "uk"); c.NoKeyProven {
3048 t.Fatalf("%s: proved keyless with a box unassessed", name)
3049 }
3050 }
3051 s := release.NewDirStore(t.TempDir())
3052 for key, body := range map[string]string{"ie-box": homeBox, "uk-box": ukNoKey, "uk-box-2": ukEligible} {
3053 if _, err := s.Put(ctx, "control/edges/"+key+".json", []byte(body), ""); err != nil {
3054 t.Fatal(err)
3055 }
3056 }
3057 snap, err := readEdgesSnapshot(ctx, failingGet{Store: s, key: "control/edges/uk-box-2.json"})
3058 if err != nil || snap.Complete {
3059 t.Fatalf("failed GET: complete=%v err=%v, want incomplete", snap.Complete, err)
3060 }
3061 if c, _ := findCandidate(regionCandidates(snap, "ie", testKeyFP), "uk"); c.NoKeyProven {
3062 t.Fatal("a failed GET let the readable keyless box prove the region keyless")
3063 }
3064}
3065
3066type failingGet struct {
3067 release.Store
3068 key string
3069}
3070
3071func (f failingGet) Get(ctx context.Context, key string) ([]byte, string, error) {
3072 if key == f.key {
3073 return nil, "", errors.New("read timeout")
3074 }
3075 return f.Store.Get(ctx, key)
3076}
3077
3078// codex plan-R2 2: a candidate's coordinates come from its ELIGIBLE boxes
3079// only; ties among them go to the lexically smallest AWS region.
3080func TestCandidateLocationComesFromEligibleBoxes(t *testing.T) {
3081 syd1 := edgeJSON("uk-syd-1", "2.2.2.1", "ap-southeast-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)})
3082 syd2 := edgeJSON("uk-syd-2", "2.2.2.2", "ap-southeast-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(false), fp: testKeyFP})
3083 c, _ := findCandidate(regionCandidates(views(t, homeBox, ukEligible, syd1, syd2), "ie", testKeyFP), "uk")
3084 if !c.Eligible || c.AWSRegion != "eu-west-2" {
3085 t.Fatalf("mixed eligibility located uk at %q, want the eligible box's eu-west-2", c.AWSRegion)
3086 }
3087 dub := edgeJSON("uk-dub", "2.2.2.3", "eu-west-1", "uk", eligibleBox)
3088 if c, _ := findCandidate(regionCandidates(views(t, homeBox, ukEligible, dub), "ie", testKeyFP), "uk"); c.AWSRegion != "eu-west-1" {
3089 t.Fatalf("eligible tie = %q, want the lexically smallest eu-west-1", c.AWSRegion)
3090 }
3091}
3092
3093func TestRegionCandidatesFoldTheHomesName(t *testing.T) {
3094 coded := edgeJSON("ie2", "5.6.7.9", "eu-west-1", "ie", eligibleBox)
3095 cands := regionCandidates(views(t, homeBox, coded), "ie", testKeyFP)
3096 if len(cands) != 1 || !cands[0].Home {
3097 t.Fatalf("candidates = %+v, want the home alone", cands)
3098 }
3099}
3100
3101// spec §6.3, §6.5: a candidate with no point is logged once per code, and
3102// the line carries no caller coordinates (there are none to carry).
3103func TestRegionCandidatesLogAMissingPointOnce(t *testing.T) {
3104 var buf bytes.Buffer
3105 restore := captureLog(&buf)
3106 defer restore()
3107 odd := edgeJSON("odd-box", "3.3.3.3", "xx-nowhere-1", "odd", eligibleBox)
3108 regionCandidates(views(t, homeBox, odd), "ie", testKeyFP)
3109 regionCandidates(views(t, homeBox, odd), "ie", testKeyFP)
3110 if n := strings.Count(buf.String(), `region "odd"`); n != 1 {
3111 t.Fatalf("missing-point line logged %d times, want once: %s", n, buf.String())
3112 }
3113}
3114
3115func TestReadEdgesSnapshotReportsListFailure(t *testing.T) {
3116 if _, err := readEdgesSnapshot(context.Background(), failingList{}); err == nil {
3117 t.Fatal("a failed listing must be an error, not an empty answer (codex R2-2)")
3118 }
3119 snap, err := readEdgesSnapshot(context.Background(), release.NewDirStore(t.TempDir()))
3120 if err != nil || len(snap.Files) != 0 || !snap.Complete {
3121 t.Fatalf("an empty prefix = %+v %v, want complete and empty", snap, err)
3122 }
3123}
3124
3125func TestAppPolicyStates(t *testing.T) {
3126 fx, _ := instanceFixture(t, "code-policy")
3127 acct := &Account{ID: OpsAccountID, Sqid: "bab"}
3128 ctx := context.Background()
3129 if p := fx.srv.appPolicy(ctx, acct, "titogo"); p.State != policyAbsent || !p.allows("uk") {
3130 t.Fatalf("no manifest = %+v", p)
3131 }
3132 m := titoManifest()
3133 m.Pool, m.Regions = "", []string{"uk"}
3134 enableInstances(t, fx, "titogo", m)
3135 if p := fx.srv.appPolicy(ctx, acct, "titogo"); p.State != policyKnown || p.allows("") || !p.allows("uk") || p.pooled() {
3136 t.Fatalf("known = %+v", p)
3137 }
3138 m.Pool = "tito"
3139 enableInstances(t, fx, "titogo", m)
3140 if !fx.srv.appPolicy(ctx, acct, "titogo").pooled() {
3141 t.Fatal("a pooled manifest must read as pooled")
3142 }
3143 if err := fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme"); err != nil {
3144 t.Fatal(err)
3145 }
3146 if p := fx.srv.appPolicy(ctx, acct, "titogo"); p.Placed != "fleet/acme" {
3147 t.Fatalf("placed = %q", p.Placed)
3148 }
3149 _, etag, err := fx.rel.Get(ctx, provision.ManifestKey("bab", "titogo"))
3150 if err != nil {
3151 t.Fatal(err)
3152 }
3153 if _, err := fx.rel.Put(ctx, provision.ManifestKey("bab", "titogo"), []byte("{not json"), etag); err != nil {
3154 t.Fatal(err)
3155 }
3156 if p := fx.srv.appPolicy(ctx, acct, "titogo"); p.State != policyUnreadable || p.allows("") {
3157 t.Fatalf("unreadable = %+v, want unreadable and nothing allowed", p)
3158 }
3159}
3160
3161// splitCapability pairs two boxes in one region that between them publish
3162// every eligibility fact while neither publishes them all (codex
3163// plan-R3 4). Task 11 reuses it for the guess.
3164var splitCapability = map[string][2]string{
3165 "keyless ordinary box beside a keyed named-pool-only box": {
3166 edgeJSON("uk-a", "1.2.3.4", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)}),
3167 edgeJSON("uk-b", "1.2.3.5", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(false), inbound: bp(true), fp: testKeyFP}),
3168 },
3169 "keyed no-inbound box beside a keyless inbound box": {
3170 edgeJSON("uk-a", "1.2.3.4", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(false), fp: testKeyFP}),
3171 edgeJSON("uk-b", "1.2.3.5", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)}),
3172 },
3173 "keyed non-hosting box beside a keyless hosting box": {
3174 edgeJSON("uk-a", "1.2.3.4", "eu-west-2", "uk", edgeOpts{hib: bp(false), shared: bp(true), inbound: bp(true), fp: testKeyFP}),
3175 edgeJSON("uk-b", "1.2.3.5", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)}),
3176 },
3177}
3178
3179// Eligibility is ONE box's facts, never the region's union of them.
3180func TestEligibilityNeverCombinesBoxes(t *testing.T) {
3181 for name, boxes := range splitCapability {
3182 cands := regionCandidates(views(t, homeBox, boxes[0], boxes[1]), "ie", testKeyFP)
3183 if c, _ := findCandidate(cands, "uk"); c.Eligible || c.unavailableReason() == "" {
3184 t.Errorf("%s: uk = %+v, want ineligible", name, c)
3185 }
3186 if n := eligibleCount(cands); n != 1 {
3187 t.Errorf("%s: %d eligible candidates, want the home alone", name, n)
3188 }
3189 }
3190}
3191```
3192
3193Task 1's `TestEligibilityPairsWithTheReconcileRefusal` pairs these with the
3194reconciler: the facts a box publishes are exactly what its reconcile pass
3195mints, so a region is offered only through one box that would mint the
3196record.
3197
3198`enableInstances` writes through `provision.PutManifest`, which replaces with
3199the current etag (`provision.go:775`), so calling it twice is safe.
3200
3201- [ ] **Step 2: Run them to verify they fail**
3202
3203Run: `go test ./internal/console -run 'TestRegionCandidates|TestReadEdgesSnapshot|TestAppPolicyStates|TestNoKeyProven|TestCandidateLocation|TestEligibilityNeverCombinesBoxes' -count=1`
3204Expected: FAIL, undefined.
3205
3206- [ ] **Step 3: Extend the view and add the strict read**
3207
3208In `edgesFilePlacementView`, after `HibernationConfigured`:
3209
3210```go
3211 // ServesSharedPool and AcceptsInbound: POINTERS, absent = a writer
3212 // predating them (unknown). SecretsKeyFP: absent = no key, or a writer
3213 // predating it; AcceptsInbound's presence tells the two apart
3214 // (nearest-region spec §6.1, R9).
3215 ServesSharedPool *bool `json:"serves_shared_pool"`
3216 AcceptsInbound *bool `json:"accepts_inbound"`
3217 SecretsKeyFP string `json:"secrets_key_fp"`
3218```
3219
3220Make `readEdgesFiles` a wrapper over the strict read:
3221
3222```go
3223func readEdgesFiles(ctx context.Context, s release.Store) []edgesFilePlacementView {
3224 if s == nil {
3225 return nil
3226 }
3227 snap, err := readEdgesSnapshot(ctx, s)
3228 if err != nil {
3229 log.Printf("console: api instances: could not list %s for the placement join: %v", edgesFilePrefix, err)
3230 return nil
3231 }
3232 return snap.Files
3233}
3234
3235// edgesSnapshot is one read of control/edges/. Complete is false when any
3236// listed file could not be read or parsed: positive facts about the boxes
3237// that WERE read still hold, but nothing may be concluded about "every
3238// box" (R9's NoKeyProven, codex plan-R2 1).
3239type edgesSnapshot struct {
3240 Files []edgesFilePlacementView
3241 Complete bool
3242}
3243
3244// readEdgesSnapshot REPORTS a failed listing instead of answering empty:
3245// the door must tell "could not read" (503, C28) from "no box publishes
3246// this code" (codex R2-2). A single unreadable or malformed file is
3247// skipped, as before, and marks the snapshot incomplete.
3248func readEdgesSnapshot(ctx context.Context, s release.Store) (edgesSnapshot, error) {
3249 keys, err := s.List(ctx, edgesFilePrefix)
3250 if err != nil {
3251 return edgesSnapshot{}, err
3252 }
3253 snap := edgesSnapshot{Complete: true}
3254 for _, key := range keys {
3255 name, ok := strings.CutPrefix(key, edgesFilePrefix)
3256 if !ok || !strings.HasSuffix(name, ".json") || strings.Contains(name, "/") {
3257 continue
3258 }
3259 body, _, err := s.Get(ctx, key)
3260 if err != nil {
3261 log.Printf("console: api instances: could not read %s for the placement join: %v", key, err)
3262 snap.Complete = false
3263 continue
3264 }
3265 var f edgesFilePlacementView
3266 if err := json.Unmarshal(body, &f); err != nil || f.V < 1 || f.Box == "" {
3267 log.Printf("console: api instances: skipping %s in the placement join: not a versioned edges file", key)
3268 snap.Complete = false
3269 continue
3270 }
3271 snap.Files = append(snap.Files, f)
3272 }
3273 return snap, nil
3274}
3275```
3276
3277- [ ] **Step 4: Implement `regioncandidates.go`**
3278
3279```go
3280package console
3281
3282import (
3283 "context"
3284 "errors"
3285 "log"
3286 "os"
3287 "sort"
3288 "sync"
3289
3290 "github.com/carlosframework/platform/internal/geo"
3291 "github.com/carlosframework/platform/internal/provision"
3292)
3293
3294// disagreeSaid and noPointSaid throttle two diagnostics to once per code.
3295var disagreeSaid, noPointSaid sync.Map
3296
3297// regionCandidate is one region an automatic choice may land in (spec
3298// §6.1). Code is in the record vocabulary: "" is the home.
3299type regionCandidate struct {
3300 Code string
3301 Home bool
3302 AWSRegion string
3303 Reachable bool // some box publishing the code has a dial address
3304 Hosting bool // ... and its activator configured (unknown counts, as in placements)
3305 KeyOK bool // ... and holds the deployment's secrets key (R9)
3306 Eligible bool // ONE box has all of it, every fact POSITIVELY true
3307 NoKeyProven bool // R9: every reachable hosting box is a current writer, and none holds the key
3308}
3309
3310// unavailableReason is "" for an eligible candidate, else the first bar it
3311// fails (the C16 vocabulary).
3312func (c regionCandidate) unavailableReason() string {
3313 switch {
3314 case c.Eligible:
3315 return ""
3316 case !c.Reachable:
3317 return "absent"
3318 case !c.Hosting:
3319 return "not-hosting"
3320 case !c.KeyOK:
3321 return "no-secrets-key"
3322 }
3323 return "not-eligible"
3324}
3325
3326func isTrue(b *bool) bool { return b != nil && *b }
3327
3328// regionCandidates folds an edges snapshot into candidates. expectedFP is
3329// the console's own secrets fingerprint; "" means it cannot judge, and then
3330// no coded region is KeyOK. The home is exempt from every eligibility fact.
3331// A coded candidate's AWS region comes only from its ELIGIBLE boxes (codex
3332// plan-R2 2); NoKeyProven needs a complete snapshot (codex plan-R2 1).
3333func regionCandidates(snap edgesSnapshot, homeName, expectedFP string) []regionCandidate {
3334 type acc struct {
3335 c regionCandidate
3336 regions map[string]int
3337 hostingBoxes int
3338 unknownWriter, keyFound bool
3339 }
3340 by := map[string]*acc{}
3341 get := func(code string, home bool) *acc {
3342 a, ok := by[code]
3343 if !ok {
3344 a = &acc{c: regionCandidate{Code: code, Home: home}, regions: map[string]int{}}
3345 by[code] = a
3346 }
3347 return a
3348 }
3349 h := get("", true)
3350 h.c.Reachable, h.c.Hosting, h.c.KeyOK, h.c.Eligible = true, true, true, true
3351 for _, f := range snap.Files {
3352 home := f.Placement == "" || (homeName != "" && f.Placement == homeName)
3353 if !home && !provision.ValidRegion(f.Placement) {
3354 continue // fleet/<name> and malformed codes are never candidates
3355 }
3356 code := f.Placement
3357 if home {
3358 code = ""
3359 }
3360 a := get(code, home)
3361 if f.EIP == "" {
3362 continue
3363 }
3364 if home {
3365 if f.Region != "" {
3366 a.regions[f.Region]++
3367 }
3368 continue
3369 }
3370 hosting := isTrue(f.HibernationConfigured) || f.HibernationConfigured == nil
3371 keyOK := expectedFP != "" && f.SecretsKeyFP == expectedFP
3372 eligible := isTrue(f.HibernationConfigured) && isTrue(f.ServesSharedPool) && isTrue(f.AcceptsInbound) && keyOK
3373 a.c.Reachable = true
3374 a.c.Hosting = a.c.Hosting || hosting
3375 if hosting {
3376 a.hostingBoxes++
3377 a.c.KeyOK = a.c.KeyOK || keyOK
3378 a.keyFound = a.keyFound || keyOK
3379 a.unknownWriter = a.unknownWriter || f.AcceptsInbound == nil
3380 }
3381 a.c.Eligible = a.c.Eligible || eligible
3382 if eligible && f.Region != "" {
3383 a.regions[f.Region]++
3384 }
3385 }
3386 out := make([]regionCandidate, 0, len(by))
3387 for _, a := range by {
3388 a.c.AWSRegion = majority(a.c.Code, a.regions)
3389 a.c.NoKeyProven = !a.c.Home && snap.Complete && expectedFP != "" && a.hostingBoxes > 0 && !a.unknownWriter && !a.keyFound
3390 if a.c.Eligible {
3391 if _, ok := geo.LookupAWSRegion(a.c.AWSRegion); !ok {
3392 if _, said := noPointSaid.LoadOrStore(a.c.Code, true); !said {
3393 log.Printf("console: region %q has no point in the shipped AWS region table (AWS region %q), so region guesses leave it out", a.c.Code, a.c.AWSRegion)
3394 }
3395 }
3396 }
3397 out = append(out, a.c)
3398 }
3399 sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code })
3400 return out
3401}
3402
3403// regionCands is regionCandidates with this console's home and expected
3404// secrets fingerprint.
3405func (s *Server) regionCands(snap edgesSnapshot) []regionCandidate {
3406 return regionCandidates(snap, s.cfg.HomeRegion, s.expectedSecretsFP())
3407}
3408
3409// candsOrHome is the candidates from a fresh snapshot, or the home alone
3410// when the listing fails: for displays and guesses that must not fail.
3411func (s *Server) candsOrHome(ctx context.Context) []regionCandidate {
3412 snap, err := readEdgesSnapshot(ctx, s.cfg.Releases)
3413 if err != nil {
3414 return homeOnlyCandidates()
3415 }
3416 return s.regionCands(snap)
3417}
3418
3419// majority picks the most common AWS region, ties to the lexically
3420// smallest, and logs a disagreement once per code.
3421func majority(code string, regions map[string]int) string {
3422 best, n := "", 0
3423 for r, c := range regions {
3424 if c > n || (c == n && r < best) {
3425 best, n = r, c
3426 }
3427 }
3428 if len(regions) > 1 {
3429 if _, said := disagreeSaid.LoadOrStore(code, true); !said {
3430 log.Printf("console: boxes for region %q disagree on their AWS region %v; using %s", code, regions, best)
3431 }
3432 }
3433 return best
3434}
3435
3436func homeOnlyCandidates() []regionCandidate {
3437 return []regionCandidate{{Code: "", Home: true, Reachable: true, Hosting: true, KeyOK: true, Eligible: true}}
3438}
3439
3440func findCandidate(cands []regionCandidate, code string) (regionCandidate, bool) {
3441 for _, c := range cands {
3442 if c.Code == code {
3443 return c, true
3444 }
3445 }
3446 return regionCandidate{}, false
3447}
3448
3449func eligibleCount(cands []regionCandidate) int {
3450 n := 0
3451 for _, c := range cands {
3452 if c.Eligible {
3453 n++
3454 }
3455 }
3456 return n
3457}
3458
3459type policyState int
3460
3461const (
3462 policyAbsent policyState = iota
3463 policyKnown
3464 policyUnreadable
3465)
3466
3467// regionPolicy is what the manifest and the fleet placement say about
3468// where an instance may land (spec §6.1).
3469type regionPolicy struct {
3470 State policyState
3471 M *provision.Manifest
3472 Placed string
3473}
3474
3475// appPolicy reads the manifest directly (never instanceManifest, which
3476// answers the caller itself) and the fleet placement. Any failure is
3477// policyUnreadable: an unrestricted policy is never invented.
3478func (s *Server) appPolicy(ctx context.Context, acct *Account, app string) regionPolicy {
3479 var p regionPolicy
3480 m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app)
3481 switch {
3482 case err == nil:
3483 p.State, p.M = policyKnown, m
3484 case errors.Is(err, os.ErrNotExist):
3485 p.State = policyAbsent
3486 default:
3487 log.Printf("console: region policy %s/%s: read manifest: %v", acct.Sqid, app, err)
3488 return regionPolicy{State: policyUnreadable}
3489 }
3490 reg, err := openRegistry(s.cfg.Registry)
3491 if err != nil {
3492 log.Printf("console: region policy %s/%s: open registry: %v", acct.Sqid, app, err)
3493 return regionPolicy{State: policyUnreadable}
3494 }
3495 placed, err := s.appPlacement(reg, acct, app)
3496 if err != nil {
3497 log.Printf("console: region policy %s/%s: placement: %v", acct.Sqid, app, err)
3498 return regionPolicy{State: policyUnreadable}
3499 }
3500 p.Placed = placed
3501 return p
3502}
3503
3504func (p regionPolicy) pooled() bool { return p.State == policyKnown && p.M.Pool != "" }
3505
3506// allows is the door's own allow-list predicate on a record-vocabulary code.
3507func (p regionPolicy) allows(code string) bool {
3508 switch p.State {
3509 case policyKnown:
3510 return instanceRegionAllowed(p.M.Regions, code)
3511 case policyAbsent:
3512 return true
3513 }
3514 return false
3515}
3516```
3517
3518The two diagnostics are O6 and O7 from the record. Neither carries anything
3519about a caller.
3520
3521- [ ] **Step 5: Run the tests to verify they pass**
3522
3523Run: `go test ./internal/console -run 'TestRegionCandidates|TestReadEdgesSnapshot|TestAppPolicyStates|TestNoKeyProven|TestCandidateLocation|TestEligibilityNeverCombinesBoxes|TestPlacements' -count=1`
3524Expected: PASS. `TestPlacements*` still passes after the `readEdgesFiles`
3525refactor.
3526
3527Then a mutation check (codex plan-R3 4): make eligibility the region's
3528union of facts. Give the accumulator four flags (any box hosting, serving
3529the shared pool, accepting inbound, holding the key), and set
3530`a.c.Eligible` from all four instead of `a.c.Eligible || eligible`. Run
3531`go test ./internal/console -run TestEligibilityNeverCombinesBoxes -count=1`.
3532It must FAIL on all three pairs. Revert, and re-run: PASS.
3533
3534- [ ] **Step 6: Commit**
3535
3536```bash
3537git add internal/console/instancesplacement.go internal/console/regioncandidates.go internal/console/regioncandidates_test.go
3538git commit -m "console: region candidates with positive per-box eligibility incl. the secrets key (nearest-region §6.1, R9)
3539
3540Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
3541```
3542
3543---
3544
3545### Task 11: The guess and its invariant
3546
3547**Files:**
3548- Create: `internal/console/regionguess.go`
3549- Test: `internal/console/regionguess_test.go`
3550
3551**Interfaces:**
3552- Consumes:
3553 - `regionCandidate`, `findCandidate` and `homeOnlyCandidates` (Task 10);
3554 - `instanceRegionAllowed`;
3555 - `geo.ZonePoint`, `geo.Nearest`, `geo.Candidate`, `geo.LookupAWSRegion`
3556 and `geo.Locator`.
3557- Produces:
3558
3559```go
3560type guessInput struct{ TZ string; IP net.IP }
3561type guessResult struct{ Code, Source string } // Code: record vocabulary
3562func guessRegion(cands []regionCandidate, allows func(code string) bool, in guessInput, loc geo.Locator) (guessResult, bool)
3563```
3564
3565- [ ] **Step 1: Write the failing tests**
3566
3567```go
3568// internal/console/regionguess_test.go
3569package console
3570
3571import (
3572 "math/rand/v2"
3573 "net"
3574 "strings"
3575 "testing"
3576
3577 "github.com/carlosframework/platform/internal/geo"
3578)
3579
3580type fakeLocator map[string]geo.Point
3581
3582func (f fakeLocator) Locate(ip net.IP) (geo.Point, bool) {
3583 p, ok := f[ip.String()]
3584 return p, ok
3585}
3586
3587func allowAll(string) bool { return true }
3588
3589func cand(code, aws string, eligible bool) regionCandidate {
3590 return regionCandidate{Code: code, Home: code == "", AWSRegion: aws,
3591 Reachable: true, Hosting: true, KeyOK: eligible, Eligible: eligible}
3592}
3593
3594var (
3595 cHome = cand("", "eu-west-1", true)
3596 cUK = cand("uk", "eu-west-2", true)
3597 cAU = cand("au", "ap-southeast-2", true)
3598)
3599
3600func TestGuessRegionOrder(t *testing.T) {
3601 loc := fakeLocator{"81.2.69.142": {Lat: -33.9, Lon: 151.2}}
3602 cands := []regionCandidate{cHome, cUK, cAU}
3603 cases := []struct {
3604 name string
3605 in guessInput
3606 want guessResult
3607 }{
3608 {"timezone first", guessInput{TZ: "Europe/London", IP: net.ParseIP("81.2.69.142")}, guessResult{"uk", "timezone"}},
3609 {"UTC falls to IP", guessInput{TZ: "Etc/UTC", IP: net.ParseIP("81.2.69.142")}, guessResult{"au", "ip"}},
3610 {"nothing maps", guessInput{TZ: "Etc/UTC"}, guessResult{"", "default"}},
3611 }
3612 for _, c := range cases {
3613 if got, ok := guessRegion(cands, allowAll, c.in, loc); !ok || got != c.want {
3614 t.Errorf("%s: got (%+v, %v), want %+v", c.name, got, ok, c.want)
3615 }
3616 }
3617 got, _ := guessRegion([]regionCandidate{cHome, cUK, cand("au", "ap-southeast-2", false)}, allowAll, guessInput{TZ: "Australia/Sydney"}, nil)
3618 if got.Code == "au" {
3619 t.Fatal("picked an ineligible region")
3620 }
3621}
3622
3623// spec §6.4, codex R3-4.
3624func TestGuessRegionNeverReturnsADisallowedHome(t *testing.T) {
3625 noPointHome := cand("", "", true)
3626 onlyUK := func(code string) bool { return instanceRegionAllowed([]string{"uk"}, code) }
3627 cands := []regionCandidate{noPointHome, cUK}
3628 if got, ok := guessRegion(cands, onlyUK, guessInput{TZ: "Europe/London"}, nil); !ok || got.Code != "uk" {
3629 t.Fatalf("mapped hint = (%+v, %v), want uk", got, ok)
3630 }
3631 if got, ok := guessRegion(cands, onlyUK, guessInput{TZ: "Etc/UTC"}, nil); ok {
3632 t.Fatalf("unmappable hint = %+v, want no guess", got)
3633 }
3634 if got, ok := guessRegion(homeOnlyCandidates(), onlyUK, guessInput{TZ: "Europe/London"}, nil); ok {
3635 t.Fatalf("edges-read failure = %+v, want no guess", got)
3636 }
3637}
3638
3639// The invariant over generated MANIFESTS, using the door's own predicate
3640// on the normalized code (spec §16, codex plan-R1 10):
3641//
3642// 1. every non-null guess passes instanceRegionAllowed and is an eligible
3643// candidate;
3644// 2. a guess exists EXACTLY when the order of §6.4 can reach one: the home
3645// is a filtered candidate, or the timezone maps and some filtered
3646// candidate has a point.
3647func TestGuessRegionInvariantProperty(t *testing.T) {
3648 codes := []string{"", "uk", "au", "us", "jp"}
3649 listable := []string{"", "ie", "uk", "au", "us", "jp"}
3650 regions := []string{"eu-west-1", "eu-west-2", "ap-southeast-2", "us-east-1", "", "xx-nowhere-1"}
3651 zones := []string{"Europe/London", "Australia/Sydney", "America/New_York", "Etc/UTC", "", "Asia/Tokyo"}
3652 r := rand.New(rand.NewPCG(1, 2))
3653 for i := 0; i < 20000; i++ {
3654 var cands []regionCandidate
3655 for _, code := range codes {
3656 if code != "" && r.IntN(3) == 0 {
3657 continue
3658 }
3659 cands = append(cands, cand(code, regions[r.IntN(len(regions))], code == "" || r.IntN(4) != 0))
3660 }
3661 var list []string
3662 for _, c := range listable {
3663 if r.IntN(3) == 0 {
3664 list = append(list, c)
3665 }
3666 }
3667 allows := func(code string) bool { return instanceRegionAllowed(list, code) }
3668 tz := zones[r.IntN(len(zones))]
3669 got, ok := guessRegion(cands, allows, guessInput{TZ: tz}, nil)
3670
3671 homeIn, anyPoint := false, false
3672 for _, c := range cands {
3673 if !c.Eligible || !allows(c.Code) {
3674 continue
3675 }
3676 homeIn = homeIn || c.Home
3677 if _, has := geo.LookupAWSRegion(c.AWSRegion); has {
3678 anyPoint = true
3679 }
3680 }
3681 _, tzMaps := geo.ZonePoint(tz)
3682 want := homeIn || (tzMaps && anyPoint)
3683 if ok != want {
3684 t.Fatalf("case %d: guess existence %v, want %v (list %v, tz %q, cands %+v)", i, ok, want, list, tz, cands)
3685 }
3686 if !ok {
3687 continue
3688 }
3689 if !allows(got.Code) {
3690 t.Fatalf("case %d: guess %+v fails the manifest %v", i, got, list)
3691 }
3692 if c, found := findCandidate(cands, got.Code); !found || !c.Eligible {
3693 t.Fatalf("case %d: guess %+v is not an eligible candidate", i, got)
3694 }
3695 }
3696}
3697
3698// Review Focus 4's core: a malformed hint is absent.
3699func TestGuessRegionToleratesGarbageHints(t *testing.T) {
3700 got, ok := guessRegion([]regionCandidate{cHome, cUK}, allowAll, guessInput{TZ: strings.Repeat("a", 10<<10)}, fakeLocator{})
3701 if !ok || got != (guessResult{"", "default"}) {
3702 t.Fatalf("garbage tz = (%+v, %v), want the home default", got, ok)
3703 }
3704}
3705
3706// codex plan-R3 4: a region whose boxes only collectively have every fact
3707// is never guessed, however near the caller is (Task 10's splitCapability).
3708func TestGuessRegionSkipsSplitCapabilityRegions(t *testing.T) {
3709 for name, boxes := range splitCapability {
3710 cands := regionCandidates(views(t, homeBox, boxes[0], boxes[1]), "ie", testKeyFP)
3711 got, ok := guessRegion(cands, allowAll, guessInput{TZ: "Europe/London"}, nil)
3712 if !ok || got.Code != "" {
3713 t.Errorf("%s: guess = %+v %v, want the home", name, got, ok)
3714 }
3715 }
3716}
3717```
3718
3719Under Task 10's union-of-facts mutation this test still passes, and that is
3720expected: a candidate's location comes only from an eligible box (codex
3721plan-R2 2), so a split region has no point to be guessed at. Task 10's
3722`TestEligibilityNeverCombinesBoxes` is the test that mutation must fail.
3723
3724The existence rule is exact. When the home is filtered, every path ends in a
3725guess: the nearest pick, or the home default. Otherwise a guess needs a
3726mapped timezone and a filtered candidate with a point, because there is no
3727IP in this property. An IP-only variant is covered by
3728`TestGuessRegionOrder`.
3729
3730- [ ] **Step 2: Run them to verify they fail**
3731
3732Run: `go test ./internal/console -run TestGuessRegion -count=1`
3733Expected: FAIL, undefined `guessRegion`.
3734
3735- [ ] **Step 3: Implement `regionguess.go`**
3736
3737```go
3738package console
3739
3740import (
3741 "net"
3742
3743 "github.com/carlosframework/platform/internal/geo"
3744)
3745
3746// guessInput carries a request's location hints for ONE call. Neither
3747// field is ever stored, logged, audited or echoed (spec §6.5).
3748type guessInput struct {
3749 TZ string
3750 IP net.IP
3751}
3752
3753// guessResult is a guess in the record vocabulary and its source word.
3754type guessResult struct{ Code, Source string }
3755
3756// guessRegion is spec §6.4:
3757//
3758// 1. filter candidates to eligible ones the policy allows;
3759// 2. nearest among those with a point, from the timezone, else (only
3760// when the timezone is unmappable) the IP;
3761// 3. else the home, ONLY if the home is itself a filtered candidate;
3762// 4. else no guess.
3763func guessRegion(cands []regionCandidate, allows func(code string) bool, in guessInput, loc geo.Locator) (guessResult, bool) {
3764 var pick []geo.Candidate
3765 homeIn := false
3766 for _, c := range cands {
3767 if !c.Eligible || !allows(c.Code) {
3768 continue
3769 }
3770 pick = append(pick, geo.Candidate{Code: c.Code, Home: c.Home, AWSRegion: c.AWSRegion})
3771 homeIn = homeIn || c.Home
3772 }
3773 if len(pick) == 0 {
3774 return guessResult{}, false
3775 }
3776 if p, ok := geo.ZonePoint(in.TZ); ok {
3777 if code, ok := geo.Nearest(p, pick); ok {
3778 return guessResult{code, "timezone"}, true
3779 }
3780 } else if loc != nil && in.IP != nil {
3781 if p, ok := loc.Locate(in.IP); ok {
3782 if code, ok := geo.Nearest(p, pick); ok {
3783 return guessResult{code, "ip"}, true
3784 }
3785 }
3786 }
3787 if homeIn {
3788 return guessResult{"", "default"}, true
3789 }
3790 return guessResult{}, false
3791}
3792```
3793
3794- [ ] **Step 4: Run the tests to verify they pass**
3795
3796Run: `go test ./internal/console -run TestGuessRegion -count=1`
3797Expected: PASS.
3798
3799- [ ] **Step 5: Mutation checks, one at a time (codex plan-R2 9)**
3800
3801Each mutation must fail the property test on its own, against a passing
3802baseline. So restore and re-run between them:
3803
38041. Run `go test ./internal/console -run TestGuessRegionInvariantProperty -count=1`.
3805 It must PASS; this is the baseline.
38062. **Mutation A:** make `guessRegion`'s first line `return guessResult{}, false`.
3807 Run the same command. It must FAIL with "guess existence false, want
3808 true". Revert, and re-run: PASS.
38093. **Mutation B:** change the final `if homeIn {` to `if true {`. This is a
3810 home fallback the policy does not allow. Run the same command. It must
3811 FAIL with "guess existence true, want false". Revert, and re-run: PASS.
38124. `git diff --stat` shows only this task's two files.
3813
3814- [ ] **Step 6: Commit**
3815
3816```bash
3817git add internal/console/regionguess.go internal/console/regionguess_test.go
3818git commit -m "console: the region guess, with existence and policy invariants property-tested (nearest-region §6.4)
3819
3820Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
3821```
3822
3823---
3824
3825### Task 12: `claimApp` resolution and the API create door
3826
3827**Files:**
3828- Create: `internal/console/regionclaim.go`
3829- Modify: `internal/console/server.go`
3830 - `claimApp`, at `:3466-3567`;
3831 - the browser call site at `:3626`, which passes `regionReq{}` until
3832 Task 13.
3833- Modify: `internal/console/apicli.go:330-470` (`handleAPICreateApp`)
3834- Modify, adding the `regionReq{}` argument:
3835 - `internal/console/apps_claim_test.go:118,134,150,165`;
3836 - `internal/console/apicli_test.go:2504`.
3837- Test: `internal/console/regionclaim_test.go`
3838
3839**Interfaces:**
3840- Consumes:
3841 - Tasks 8 to 11;
3842 - `s.clientIP` (`authcli.go:876`), `principalFrom`, `placements` and
3843 `hostingSet`;
3844 - `homeRegionIn` and `homeRegionOut`;
3845 - the record's O8.
3846- Produces:
3847
3848```go
3849type regionReq struct {
3850 explicit string // a person's choice, record vocabulary
3851 hasExplicit bool
3852 tz string
3853 ip net.IP // request-local, person requests only
3854}
3855func (s *Server) validateExplicitRegion(ctx context.Context, raw string) (code string, ref *doorRefusal)
3856type explicitGate struct{ caps map[string]placementCapability; published bool; cands []regionCandidate }
3857func (s *Server) readExplicitGate(ctx context.Context) explicitGate
3858func (g explicitGate) refusal(code string) string // "", unknown, not-armed, no-key
3859func (s *Server) explicitChoices(g explicitGate) []string
3860func (s *Server) resolveNewAppRegion(ctx context.Context, rq regionReq, adopting bool) (region, source string)
3861func (s *Server) placeOf(code string, cands []regionCandidate) string
3862func (s *Server) personIP(r *http.Request) net.IP
3863func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, rq regionReq) (claimOutcome, string, *apexHold)
3864```
3865
3866`handleAPICreateApp`'s response gains `region`, `region_name`,
3867`region_source` and `regions_available`.
3868
3869C18 and C19 list exactly the codes `validateExplicitRegion` would accept
3870now, through one predicate, `explicitGate.refusal`: the home's name, plus
3871each published code that is shape-valid, armed and hosting, and not proved
3872keyless (R9). They never list a code the same door then refuses (codex
3873plan-R4 2), and the test submits every listed code and expects it to pass.
3874
3875- [ ] **Step 1: Write the failing tests**
3876
3877```go
3878// internal/console/regionclaim_test.go
3879package console
3880
3881import (
3882 "bytes"
3883 "context"
3884 "encoding/json"
3885 "fmt"
3886 "net/http"
3887 "strings"
3888 "testing"
3889 "time"
3890
3891 "github.com/carlosframework/platform/internal/geo"
3892 "github.com/carlosframework/platform/internal/registry"
3893 "github.com/carlosframework/platform/internal/svcrole"
3894)
3895
3896func regionFixture(t *testing.T) (*consoleFixture, string) {
3897 t.Helper()
3898 fx := newConsoleFixture(t)
3899 fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion, fx.srv.cfg.SecretsPub = true, "ie", testKeyPub
3900 setEdges(t, fx, map[string]string{"ie-box": homeBox, "uk-box": ukEligible})
3901 return fx, cliToken(t, fx, "paul@keymail.dev", "code-region-claim")
3902}
3903
3904func newAppURL(fx *consoleFixture) string { return fx.ts.URL + "/api/cli/apps/bab/new" }
3905
3906// postJSONFrom is postJSON with an X-Forwarded-For, which the console
3907// trusts from the loopback edge (authcli.go clientIP).
3908func postJSONFrom(t *testing.T, u string, body any, bearer, xff string) (int, map[string]any) {
3909 t.Helper()
3910 raw, _ := json.Marshal(body)
3911 req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(raw))
3912 if err != nil {
3913 t.Fatal(err)
3914 }
3915 req.Header.Set("Content-Type", "application/json")
3916 req.Header.Set("Authorization", "Bearer "+bearer)
3917 req.Header.Set("X-Forwarded-For", xff)
3918 return doJSON(t, req)
3919}
3920
3921func errOf(res map[string]any) string { s, _ := res["error"].(string); return s }
3922
3923var ukPlace = geo.PlaceName("eu-west-2", "uk")
3924
3925func TestAPICreateAppResolvesRegion(t *testing.T) {
3926 fx, token := regionFixture(t)
3927 status, res := postJSON(t, newAppURL(fx), map[string]string{"name": "blog", "timezone": "Europe/London"}, token)
3928 if status != http.StatusOK || res["region"] != "uk" || res["region_source"] != "timezone" ||
3929 res["region_name"] != ukPlace || res["regions_available"] != float64(2) {
3930 t.Fatalf("create = %d %v", status, res)
3931 }
3932 if r, src, _ := fx.st.AppRegion(OpsAccountID, "blog"); r != "uk" || src != "timezone" {
3933 t.Fatalf("stored %q %q", r, src)
3934 }
3935 // An old CLI's body, from loopback with no forwarded address.
3936 if _, res = postJSON(t, newAppURL(fx), map[string]string{"name": "old"}, token); res["region"] != "ie" || res["region_source"] != "default" {
3937 t.Fatalf("old body = %v", res)
3938 }
3939 // An explicit home name is a choice, stored as "".
3940 postJSON(t, newAppURL(fx), map[string]string{"name": "athome", "region": "ie"}, token)
3941 if r, src, _ := fx.st.AppRegion(OpsAccountID, "athome"); r != "" || src != "chosen" {
3942 t.Fatalf("explicit home = %q %q", r, src)
3943 }
3944}
3945
3946// The old-body path through the real door: a person's forwarded address
3947// is located; a service principal's never is (spec §6.2).
3948func TestAPICreateAppLocatesOnlyAPerson(t *testing.T) {
3949 fx, token := regionFixture(t)
3950 fx.srv.cfg.GeoIP = fakeLocator{"81.2.69.142": {Lat: 51.5, Lon: -0.12}}
3951 if status, res := postJSONFrom(t, newAppURL(fx), map[string]string{"name": "person"}, token, "81.2.69.142"); status != http.StatusOK ||
3952 res["region"] != "uk" || res["region_source"] != "ip" {
3953 t.Fatalf("person = %d %v", status, res)
3954 }
3955 svc := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
3956 if status, res := postJSONFrom(t, newAppURL(fx), map[string]string{"name": "hubapp"}, svc, "81.2.69.142"); status != http.StatusOK ||
3957 res["region"] != "ie" || res["region_source"] != "default" {
3958 t.Fatalf("service = %d %v, want no IP step", status, res)
3959 }
3960}
3961
3962func TestAPICreateAppRefusesBadExplicitRegions(t *testing.T) {
3963 fx, token := regionFixture(t)
3964 // nz cannot host; au's current-writer box lacks the key (R9).
3965 auNoKey := edgeJSON("au-box", "9.9.9.8", "ap-southeast-2", "au", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)})
3966 setEdges(t, fx, map[string]string{"nz-box": nzNoHosting, "au-box": auNoKey})
3967 // codex plan-R4 2: each list names exactly what this door would take.
3968 choices := []string{"ie", "uk"}
3969 cases := []struct{ name, region, want string }{
3970 {"xa", "Bad/Code", msgRegionUnknown("Bad/Code", choices)},
3971 {"xb", "nz", msgRegionNotArmed("nz", choices)},
3972 {"xc", "au", msgNoSecretsKey("au")},
3973 }
3974 for _, c := range cases {
3975 status, res := postJSON(t, newAppURL(fx), map[string]string{"name": c.name, "region": c.region}, token)
3976 if status != http.StatusBadRequest || errOf(res) != c.want {
3977 t.Errorf("region %q = %d %q, want 400 %q", c.region, status, errOf(res), c.want)
3978 }
3979 if has, _ := fx.st.HasAppIn(OpsAccountID, c.name); has {
3980 t.Errorf("a refused region still claimed %s", c.name)
3981 }
3982 }
3983 // Every advertised choice is accepted by the same door, same fixture.
3984 for i, code := range choices {
3985 name := fmt.Sprintf("alt%d", i)
3986 if status, res := postJSON(t, newAppURL(fx), map[string]string{"name": name, "region": code}, token); status != http.StatusOK {
3987 t.Errorf("advertised %q refused: %d %v", code, status, res)
3988 }
3989 }
3990 // R9: a region whose current-writer boxes lack the key.
3991 setEdges(t, fx, map[string]string{"uk-box": ukNoKey})
3992 if status, res := postJSON(t, newAppURL(fx), map[string]string{"name": "xd", "region": "uk"}, token); status != http.StatusBadRequest || errOf(res) != msgNoSecretsKey("uk") {
3993 t.Fatalf("keyless uk = %d %v, want C31", status, res)
3994 }
3995}
3996
3997func TestAPICreateAppSwitchOffStoresTheHome(t *testing.T) {
3998 fx, token := regionFixture(t)
3999 fx.srv.cfg.AppRegions = false
4000 _, res := postJSON(t, newAppURL(fx), map[string]string{"name": "off", "region": "uk", "timezone": "Europe/London"}, token)
4001 if r, src, _ := fx.st.AppRegion(OpsAccountID, "off"); r != "" || src != "default" || res["region"] != "ie" {
4002 t.Fatalf("switch off = %q %q %v", r, src, res)
4003 }
4004 fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion = true, ""
4005 postJSON(t, newAppURL(fx), map[string]string{"name": "unnamed", "region": "uk"}, token)
4006 if r, _, _ := fx.st.AppRegion(OpsAccountID, "unnamed"); r != "" {
4007 t.Fatalf("unnamed home stored %q", r)
4008 }
4009}
4010
4011func TestClaimAdoptionDiscardsTheGuess(t *testing.T) {
4012 fx, _ := regionFixture(t)
4013 acct := &Account{ID: OpsAccountID, Sqid: "bab"}
4014 seedBucketApp(t, fx, "bab", "oldsite")
4015 if out, _, _ := fx.srv.claimApp(context.Background(), acct, "oldsite", "paul@keymail.dev", "", true, regionReq{tz: "Europe/London"}); out != claimOK {
4016 t.Fatalf("adoption = %v", out)
4017 }
4018 if r, src, _ := fx.st.AppRegion(OpsAccountID, "oldsite"); r != "" || src != "default" {
4019 t.Fatalf("adoption with a guess = %q %q", r, src)
4020 }
4021 seedBucketApp(t, fx, "bab", "oldsite2")
4022 fx.srv.claimApp(context.Background(), acct, "oldsite2", "paul@keymail.dev", "", true, regionReq{explicit: "uk", hasExplicit: true})
4023 if r, src, _ := fx.st.AppRegion(OpsAccountID, "oldsite2"); r != "uk" || src != "chosen" {
4024 t.Fatalf("adoption with an explicit choice = %q %q", r, src)
4025 }
4026}
4027
4028// codex plan-R6 1, spec §6.5: every successful creation logs exactly one
4029// region line, naming what was stored, whatever the switch says: on, off,
4030// and on with an unnamed home (which keeps assignment off).
4031func TestClaimLogsOneRegionLineInEverySwitchState(t *testing.T) {
4032 for _, c := range []struct {
4033 name, app, home string
4034 on bool
4035 stored, source string
4036 line string
4037 }{
4038 {"assignment on", "on1", "ie", true, "uk", "chosen", "console: app region bab/on1: uk (chosen)"},
4039 {"assignment off", "off1", "ie", false, "", "default", "console: app region bab/off1: ie (default)"},
4040 {"on, home unnamed", "unnamed1", "", true, "", "default", "console: app region bab/unnamed1: (default)"},
4041 } {
4042 fx, token := regionFixture(t)
4043 fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion = c.on, c.home
4044 var logs bytes.Buffer
4045 restore := captureLog(&logs)
4046 status, res := postJSON(t, newAppURL(fx), map[string]string{"name": c.app, "region": "uk"}, token)
4047 restore()
4048 if status != http.StatusOK {
4049 t.Fatalf("%s: create = %d %v", c.name, status, res)
4050 }
4051 if r, src, _ := fx.st.AppRegion(OpsAccountID, c.app); r != c.stored || src != c.source {
4052 t.Fatalf("%s: stored %q %q, want %q %q", c.name, r, src, c.stored, c.source)
4053 }
4054 if n := strings.Count(logs.String(), "console: app region bab/"+c.app+":"); n != 1 || !strings.Contains(logs.String(), c.line+"\n") {
4055 t.Errorf("%s: %d region line(s) in %q, want exactly %q", c.name, n, logs.String(), c.line)
4056 }
4057 }
4058}
4059
4060// Review Focus 5: a competing insert that lands AFTER the region was
4061// resolved loses the claim: no row of ours, no region log line.
4062func TestClaimLostAfterResolutionWritesNothing(t *testing.T) {
4063 fx, token := regionFixture(t)
4064 fx.srv.beforeClaimInsert = func() {
4065 if _, err := fx.st.CreateAppInRegion(OpsAccountID, "racy", "rival@k.dev", "", "chosen", time.Now()); err != nil {
4066 t.Error(err)
4067 }
4068 }
4069 var logs bytes.Buffer
4070 restore := captureLog(&logs)
4071 status, _ := postJSON(t, newAppURL(fx), map[string]string{"name": "racy", "region": "uk"}, token)
4072 restore()
4073 if status != http.StatusConflict {
4074 t.Fatalf("lost race = %d, want 409", status)
4075 }
4076 if r, src, _ := fx.st.AppRegion(OpsAccountID, "racy"); r != "" || src != "chosen" {
4077 t.Fatalf("the rival's row changed: %q %q", r, src)
4078 }
4079 if strings.Contains(logs.String(), "app region bab/racy") {
4080 t.Fatalf("a lost claim logged a region line: %s", logs.String())
4081 }
4082}
4083
4084// Review Focus 5: a held apex refuses before resolution, so nothing is
4085// written or logged.
4086func TestClaimHeldApexWritesNothing(t *testing.T) {
4087 fx, token := regionFixture(t)
4088 fx.srv.cfg.AppsDomain = "oncarlos.test"
4089 fx.addRoute(t, registry.Route{Host: "beta." + fx.sqid + ".oncarlos.test", App: "kass", Account: fx.sqid, Kind: "instance"})
4090 var logs bytes.Buffer
4091 restore := captureLog(&logs)
4092 status, _ := postJSON(t, newAppURL(fx), map[string]string{"name": "beta", "timezone": "Europe/London"}, token)
4093 restore()
4094 if status != http.StatusConflict {
4095 t.Fatalf("held apex = %d, want 409", status)
4096 }
4097 if has, _ := fx.st.HasAppIn(OpsAccountID, "beta"); has {
4098 t.Fatal("beta was created")
4099 }
4100 if strings.Contains(logs.String(), "app region bab/beta") {
4101 t.Fatalf("a refused claim logged a region line: %s", logs.String())
4102 }
4103}
4104```
4105
4106`fx.sqid` is the fixture account's sqid, `bab`. `fx.addRoute` is
4107`apex_test.go`'s helper. `doJSON` is `authcli_test.go`'s. `serviceToken` is
4108`svcfixture_test.go:14`.
4109
4110- [ ] **Step 2: Run them to verify they fail**
4111
4112Run: `go test ./internal/console -run 'TestAPICreateApp|TestClaim' -count=1`
4113Expected: FAIL to compile: `claimApp` has the wrong arity, and `regionReq` is
4114undefined.
4115
4116- [ ] **Step 3: Implement `regionclaim.go`**
4117
4118```go
4119package console
4120
4121import (
4122 "context"
4123 "net"
4124 "net/http"
4125 "sort"
4126 "strings"
4127
4128 "github.com/carlosframework/platform/internal/geo"
4129 "github.com/carlosframework/platform/internal/provision"
4130)
4131
4132// regionReq is what a create door hands claimApp: never a resolved region.
4133// The guess is resolved INSIDE claimApp, after it knows whether this is an
4134// adoption (spec §5.5). ip is request-local and only set for a person.
4135type regionReq struct {
4136 explicit string
4137 hasExplicit bool
4138 tz string
4139 ip net.IP
4140}
4141
4142// validateExplicitRegion checks a person's explicit region as the instance
4143// door checks one (spec §8.1): ValidRegion; armed and hosting unless the
4144// gate is dormant; not provably without the secrets key (R9). The home's
4145// name normalizes to "". A refusal lists exactly the codes this same check
4146// would accept now (codex plan-R4 2).
4147func (s *Server) validateExplicitRegion(ctx context.Context, raw string) (string, *doorRefusal) {
4148 code := s.homeRegionIn(strings.TrimSpace(raw))
4149 if code == "" {
4150 return "", nil
4151 }
4152 gate := s.readExplicitGate(ctx)
4153 switch gate.refusal(code) {
4154 case "":
4155 return code, nil
4156 case "unknown":
4157 return "", &doorRefusal{http.StatusBadRequest, msgRegionUnknown(code, s.explicitChoices(gate))}
4158 case "not-armed":
4159 return "", &doorRefusal{http.StatusBadRequest, msgRegionNotArmed(code, s.explicitChoices(gate))}
4160 }
4161 return "", &doorRefusal{http.StatusBadRequest, msgNoSecretsKey(code)}
4162}
4163
4164// explicitGate is one read of what an explicit app region must pass.
4165type explicitGate struct {
4166 caps map[string]placementCapability
4167 published bool
4168 cands []regionCandidate // nil when the edges read failed: R9 cannot refuse
4169}
4170
4171func (s *Server) readExplicitGate(ctx context.Context) explicitGate {
4172 g := explicitGate{}
4173 g.caps, g.published = placements(ctx, s.cfg.Releases)
4174 if snap, err := readEdgesSnapshot(ctx, s.cfg.Releases); err == nil {
4175 g.cands = s.regionCands(snap)
4176 }
4177 return g
4178}
4179
4180// refusal is "" when an explicit, record-vocabulary code passes, else
4181// which bar it fails: "unknown", "not-armed" or "no-key".
4182func (g explicitGate) refusal(code string) string {
4183 switch {
4184 case code == "":
4185 return ""
4186 case !provision.ValidRegion(code):
4187 return "unknown"
4188 case g.published && !(g.caps[code].Reachable && g.caps[code].Hosting):
4189 return "not-armed"
4190 }
4191 if c, ok := findCandidate(g.cands, code); ok && c.NoKeyProven {
4192 return "no-key"
4193 }
4194 return ""
4195}
4196
4197// explicitChoices is the home's name plus every published code that
4198// refusal passes, sorted: never a code this door would refuse.
4199func (s *Server) explicitChoices(g explicitGate) []string {
4200 set := map[string]bool{}
4201 if s.cfg.HomeRegion != "" {
4202 set[s.cfg.HomeRegion] = true
4203 }
4204 for code := range g.caps {
4205 if c := s.homeRegionIn(code); c != "" && g.refusal(c) == "" {
4206 set[c] = true
4207 }
4208 }
4209 out := make([]string, 0, len(set))
4210 for c := range set {
4211 out = append(out, c)
4212 }
4213 sort.Strings(out)
4214 return out
4215}
4216
4217// resolveNewAppRegion is spec §5.5 and §5.6 for a NEW row.
4218func (s *Server) resolveNewAppRegion(ctx context.Context, rq regionReq, adopting bool) (region, source string) {
4219 if !s.appRegionsOn() {
4220 return "", "default"
4221 }
4222 if rq.hasExplicit {
4223 return rq.explicit, "chosen"
4224 }
4225 if adopting {
4226 return "", "default"
4227 }
4228 files, err := readEdgesSnapshot(ctx, s.cfg.Releases)
4229 if err != nil {
4230 return "", "default"
4231 }
4232 g, ok := guessRegion(s.regionCands(files), func(string) bool { return true },
4233 guessInput{TZ: rq.tz, IP: rq.ip}, s.cfg.GeoIP)
4234 if !ok {
4235 return "", "default"
4236 }
4237 return g.Code, g.Source
4238}
4239
4240// placeOf names a record-vocabulary code: its AWS region's place, else the
4241// code under its name.
4242func (s *Server) placeOf(code string, cands []regionCandidate) string {
4243 c, _ := findCandidate(cands, code)
4244 return geo.PlaceName(c.AWSRegion, s.homeRegionOut(code))
4245}
4246
4247// personIP is the request's own address, for a person only (spec §6.2).
4248// A service or app principal is a server; its address says nothing about
4249// where a person is.
4250func (s *Server) personIP(r *http.Request) net.IP {
4251 if p, ok := principalFrom(r.Context()); ok && p.Kind != PrincipalUser {
4252 return nil
4253 }
4254 return net.ParseIP(s.clientIP(r))
4255}
4256```
4257
4258`personIP` treats "no principal in the context" as a person. That is the
4259browser door, where the session is a person by construction.
4260
4261- [ ] **Step 4: Thread `regionReq` through `claimApp`**
4262
4263Change the signature, and replace the insert at `server.go:3550`:
4264
4265```go
4266func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, rq regionReq) (outcome claimOutcome, placed string, held *apexHold) {
4267```
4268
4269```go
4270 region, source := s.resolveNewAppRegion(ctx, rq, adoptingExistingApp)
4271 if s.beforeClaimInsert != nil {
4272 s.beforeClaimInsert()
4273 }
4274 created, err := s.store.CreateAppInRegion(acct.ID, name, addr, region, source, time.Now())
4275 if err != nil {
4276 return claimCreateFailed, "", nil
4277 }
4278 if !created { // lost a claim race after the checks
4279 return claimTaken, "", nil
4280 }
4281 // Spec §6.5: one line per creation, in every switch state (codex
4282 // plan-R6 1). Only reached once the row is ours.
4283 log.Printf("console: app region %s/%s: %s (%s)", acct.Sqid, name, s.homeRegionOut(region), source)
4284```
4285
4286The log text is O8. It is logged for every successful creation, with the
4287switch on or off and with the home named or not, exactly as spec §6.5
4288says: one line per creation, naming what was stored. With an unnamed home
4289the code is the record's `""`, so the line reads `bab/app: (default)`.
4290A claim that loses its race or is refused logs nothing, because the line
4291comes after both checks. `TestClaimLogsOneRegionLineInEverySwitchState`
4292pins all three states. Add `, regionReq{}` to every existing call:
4293`server.go:3626`, `apps_claim_test.go:118,134,150,165` and
4294`apicli_test.go:2504`.
4295
4296- [ ] **Step 5: Extend `handleAPICreateApp`**
4297
4298Add the request fields:
4299
4300```go
4301 Region string `json:"region"`
4302 Timezone string `json:"timezone"`
4303```
4304
4305After the `place` validation, before `ownerClaim`:
4306
4307```go
4308 rq := regionReq{tz: req.Timezone, ip: s.personIP(r)}
4309 if strings.TrimSpace(req.Region) != "" {
4310 code, ref := s.validateExplicitRegion(r.Context(), req.Region)
4311 if ref != nil {
4312 jsonError(w, ref.Status, ref.Msg)
4313 return
4314 }
4315 rq.explicit, rq.hasExplicit = code, true
4316 }
4317```
4318
4319Pass `rq` to `claimApp`. In `claimOK`:
4320
4321```go
4322 gotRegion, gotSource, err := s.store.AppRegion(acct.ID, name)
4323 if err != nil {
4324 log.Printf("console: claim %s/%s: read back region: %v", acct.Sqid, name, err)
4325 }
4326 files, ferr := readEdgesSnapshot(r.Context(), s.cfg.Releases)
4327 cands := homeOnlyCandidates()
4328 if ferr == nil {
4329 cands = s.regionCands(files)
4330 }
4331```
4332
4333Add to the response map:
4334
4335```go
4336 "region": s.homeRegionOut(gotRegion),
4337 "region_name": s.placeOf(gotRegion, cands),
4338 "region_source": gotSource,
4339 "regions_available": eligibleCount(cands),
4340```
4341
4342- [ ] **Step 6: Run the tests to verify they pass**
4343
4344Run: `go test ./internal/console -count=1`
4345Expected: PASS, including every existing claim test.
4346
4347Then the log check (codex plan-R6 1): wrap the `log.Printf` back in
4348`if s.appRegionsOn() { … }`, and run
4349`go test ./internal/console -run TestClaimLogsOneRegionLineInEverySwitchState -count=1`.
4350It must FAIL on `assignment off` and `on, home unnamed` (0 lines). Revert,
4351and re-run: PASS.
4352
4353Then a mutation check (codex plan-R4 2): in `explicitChoices`, also accept
4354codes whose refusal is `no-key`. Run
4355`go test ./internal/console -run TestAPICreateAppRefusesBadExplicitRegions -count=1`.
4356It must FAIL (`au` is listed). Revert, and re-run: PASS.
4357
4358- [ ] **Step 7: Commit**
4359
4360```bash
4361git add internal/console/regionclaim.go internal/console/regionclaim_test.go internal/console/server.go internal/console/apicli.go internal/console/apps_claim_test.go internal/console/apicli_test.go
4362git commit -m "console: resolve the app region inside claimApp; API create takes region and timezone (nearest-region §5.5, §8.1, §9.1, R9)
4363
4364Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
4365```
4366
4367---
4368
4369### Task 13: The new-app page picker, POST and browser guess route
4370
4371**Files:**
4372- Create: `internal/console/regionpicker.go`
4373- Modify: `internal/console/server.go`
4374 - the `page` struct, at `:1350`;
4375 - `handleNewAppForm`, at `:3586`;
4376 - `handleNewApp`, at `:3607`;
4377 - the routes, at `:1152-1153`.
4378- Modify: `internal/console/templates/app_new.html`
4379- Test: `internal/console/regionpicker_test.go`
4380
4381**Interfaces:**
4382- Consumes: Tasks 6, 10, 11 and 12.
4383- Produces:
4384
4385```go
4386const autoRegion = "@auto"
4387type regionOption struct{ Value, Label string }
4388type regionPickerView struct{ Auto string; Options []regionOption; Attribution bool }
4389func (s *Server) newAppPicker(ctx context.Context, ip net.IP) *regionPickerView // nil = no picker
4390func (s *Server) pickedRegion(ctx context.Context, r *http.Request) (regionReq, bool)
4391func (s *Server) handleNewAppRegion(w http.ResponseWriter, r *http.Request)
4392```
4393
4394The `page` struct gains the field `RegionPicker *regionPickerView`.
4395
4396- [ ] **Step 1: Write the failing tests**
4397
4398`html/template` writes `'` as `&#39;`, so every assertion on rendered copy
4399compares the escaped form, both positive and negative (codex plan-R1 8).
4400
4401```go
4402// internal/console/regionpicker_test.go
4403package console
4404
4405import (
4406 "encoding/json"
4407 "html"
4408 "net/http"
4409 "net/url"
4410 "regexp"
4411 "strings"
4412 "testing"
4413)
4414
4415func has(body, plain string) bool { return strings.Contains(body, html.EscapeString(plain)) }
4416
4417// newAppFormGolden is the base tree's own rendering of the create-app form
4418// (the branch base, switch off), with the session's CSRF value blanked.
4419const newAppFormGolden = `<form class="signin" method="post" action="/a/bab/apps/new">
4420 <input type="hidden" name="csrf" value="CSRF">
4421 <label class="visually-hidden" for="app-name">App name</label>
4422 <input id="app-name" type="text" name="name" placeholder="my-app" autofocus
4423 required pattern="[A-Za-z][A-Za-z0-9-]{0,31}" maxlength="32">
4424 <button type="submit">Create app</button>
4425</form>`
4426
4427// newAppForm is the rendered <form>…</form>, CSRF value blanked.
4428func newAppForm(t *testing.T, body string) string {
4429 t.Helper()
4430 i := strings.Index(body, `<form class="signin"`)
4431 if i < 0 {
4432 t.Fatalf("no create-app form in %s", body)
4433 }
4434 j := strings.Index(body[i:], "</form>") + i + len("</form>")
4435 return regexp.MustCompile(`name="csrf" value="[^"]*"`).ReplaceAllString(body[i:j], `name="csrf" value="CSRF"`)
4436}
4437
4438// codex plan-R4 3, spec §10 and §16: whenever there is no picker, the form
4439// is today's form byte for byte: with the switch off, and with the switch
4440// on but only one eligible region.
4441func TestNewAppPageWithoutPickerIsUnchanged(t *testing.T) {
4442 off := newConsoleFixture(t)
4443 one, _ := regionFixture(t)
4444 setEdges(t, one, map[string]string{"uk-box": ukNoKey}) // the home alone is eligible
4445 for name, fx := range map[string]*consoleFixture{"switch off": off, "one eligible region": one} {
4446 _, _, body := get(t, signInPaul(t, fx), fx.u("/apps/new"))
4447 if got := newAppForm(t, body); got != newAppFormGolden {
4448 t.Errorf("%s: form =\n%q\nwant\n%q", name, got, newAppFormGolden)
4449 }
4450 }
4451}
4452
4453func TestNewAppPickerAndAutoChoice(t *testing.T) {
4454 fx, _ := regionFixture(t)
4455 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-picker")
4456 _, _, body := get(t, c, fx.u("/apps/new"))
4457 for _, want := range []string{`name="region"`, `value="@auto"`, `value="uk"`, `value="ie"`} {
4458 if !strings.Contains(body, want) {
4459 t.Fatalf("picker missing %q", want)
4460 }
4461 }
4462 if !has(body, ukPlace) || !has(body, regionCopy.PickerLabel) || !has(body, regionCopy.PickerHelp) {
4463 t.Fatal("picker missing its copy")
4464 }
4465 if status, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"auto1"}, "region": {"@auto"}, "tz": {"Europe/London"}}); status != http.StatusSeeOther {
4466 t.Fatalf("@auto post = %d", status)
4467 }
4468 if r, src, _ := fx.st.AppRegion(OpsAccountID, "auto1"); r != "uk" || src != "timezone" {
4469 t.Fatalf("@auto = %q %q", r, src)
4470 }
4471 postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"chose"}, "region": {"ie"}, "tz": {"Europe/London"}})
4472 if r, src, _ := fx.st.AppRegion(OpsAccountID, "chose"); r != "" || src != "chosen" {
4473 t.Fatalf("explicit ie = %q %q", r, src)
4474 }
4475}
4476
4477// codex R2-3: a home NAMED "auto" is a region, not the sentinel.
4478func TestRegionNamedAutoIsNotTheSentinel(t *testing.T) {
4479 fx, _ := regionFixture(t)
4480 fx.srv.cfg.HomeRegion = "auto"
4481 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-auto-name")
4482 postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"homeauto"}, "region": {"auto"}})
4483 if r, src, _ := fx.st.AppRegion(OpsAccountID, "homeauto"); r != "" || src != "chosen" {
4484 t.Fatalf("explicit home named auto = %q %q", r, src)
4485 }
4486}
4487
4488// Review Focus 3: uk's box loses the secrets key between render and POST.
4489func TestNewAppPostWithANoLongerEligibleRegion(t *testing.T) {
4490 fx, _ := regionFixture(t)
4491 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-stale")
4492 if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, `value="uk"`) {
4493 t.Fatal("precondition: uk offered")
4494 }
4495 setEdges(t, fx, map[string]string{"uk-box": ukNoKey})
4496 status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"stale"}, "region": {"uk"}})
4497 if status != http.StatusSeeOther || loc != fx.path("/apps/new") {
4498 t.Fatalf("stale region = %d %q, want back to the form", status, loc)
4499 }
4500 if _, _, body := get(t, c, fx.u("/apps/new")); !has(body, regionCopy.Unavailable) {
4501 t.Fatal("no C5 flash")
4502 }
4503 if exists, _ := fx.st.HasAppIn(OpsAccountID, "stale"); exists {
4504 t.Fatal("a refused region still created the app")
4505 }
4506}
4507
4508func TestNewAppRegionRouteNeedsSessionAndCSRF(t *testing.T) {
4509 fx, _ := regionFixture(t)
4510 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-route")
4511 status, _, body := postForm(t, c, fx.u("/apps/new/region"), url.Values{"csrf": {csrf}, "tz": {"Europe/London"}})
4512 var res map[string]string
4513 if err := json.Unmarshal([]byte(body), &res); err != nil {
4514 t.Fatalf("guess route body %q: %v", body, err)
4515 }
4516 if status != http.StatusOK || res["code"] != "uk" || res["source"] != "timezone" || res["name"] != ukPlace {
4517 t.Fatalf("guess route = %d %v", status, res)
4518 }
4519 if status, _, _ := postForm(t, c, fx.u("/apps/new/region"), url.Values{"csrf": {"wrong"}, "tz": {"Europe/London"}}); status != http.StatusForbidden {
4520 t.Fatalf("bad csrf = %d, want 403", status)
4521 }
4522 if status, _, _ := postForm(t, newBrowser(t), fx.u("/apps/new/region"), url.Values{"tz": {"Europe/London"}}); status == http.StatusOK {
4523 t.Fatal("no session got a guess")
4524 }
4525}
4526```
4527
4528`signInPaul` (`server_test.go:450`) returns just the client. `signInAs`
4529(`svcpage_test.go:59`) also returns the CSRF token.
4530
4531- [ ] **Step 2: Run them to verify they fail**
4532
4533Run: `go test ./internal/console -run 'TestNewApp|TestRegionNamedAuto' -count=1`
4534Expected: FAIL.
4535
4536- [ ] **Step 3: Implement `regionpicker.go`**
4537
4538```go
4539package console
4540
4541import (
4542 "context"
4543 "crypto/subtle"
4544 "fmt"
4545 "net"
4546 "net/http"
4547 "strings"
4548)
4549
4550// autoRegion is the picker's "let the server guess" value. "@" is outside
4551// the placement-code grammar, so no real region collides with it (codex
4552// R2-3).
4553const autoRegion = "@auto"
4554
4555type regionOption struct{ Value, Label string }
4556
4557type regionPickerView struct {
4558 Auto string // the @auto option's label (C3)
4559 Options []regionOption
4560 Attribution bool // C24, when the IP step is configured
4561}
4562
4563// newAppPicker builds the picker, or nil: switch off, an edges read
4564// failure, or fewer than two eligible regions (§10). The server's own
4565// guess is IP, then home; the page script relabels @auto with the
4566// browser's timezone guess.
4567func (s *Server) newAppPicker(ctx context.Context, ip net.IP) *regionPickerView {
4568 if !s.appRegionsOn() {
4569 return nil
4570 }
4571 files, err := readEdgesSnapshot(ctx, s.cfg.Releases)
4572 if err != nil {
4573 return nil
4574 }
4575 cands := s.regionCands(files)
4576 if eligibleCount(cands) < 2 {
4577 return nil
4578 }
4579 g, _ := guessRegion(cands, func(string) bool { return true }, guessInput{IP: ip}, s.cfg.GeoIP)
4580 v := &regionPickerView{
4581 Auto: fmt.Sprintf(regionCopy.AutoOption, s.placeOf(g.Code, cands)),
4582 Attribution: s.cfg.GeoIP != nil,
4583 }
4584 for _, c := range cands {
4585 if c.Eligible {
4586 v.Options = append(v.Options, regionOption{Value: s.homeRegionOut(c.Code), Label: s.placeOf(c.Code, cands)})
4587 }
4588 }
4589 return v
4590}
4591
4592// pickedRegion turns the form's region field into a regionReq. ok=false
4593// means the submitted code is not an eligible region NOW (C5).
4594func (s *Server) pickedRegion(ctx context.Context, r *http.Request) (regionReq, bool) {
4595 rq := regionReq{tz: r.FormValue("tz"), ip: s.personIP(r)}
4596 v := strings.TrimSpace(r.FormValue("region"))
4597 if v == "" || v == autoRegion {
4598 return rq, true
4599 }
4600 files, err := readEdgesSnapshot(ctx, s.cfg.Releases)
4601 if err != nil {
4602 return rq, false
4603 }
4604 code := s.homeRegionIn(v)
4605 c, found := findCandidate(s.regionCands(files), code)
4606 if !found || !c.Eligible {
4607 return rq, false
4608 }
4609 rq.explicit, rq.hasExplicit = code, true
4610 return rq, true
4611}
4612
4613// handleNewAppRegion is the page script's guess (spec §9.5): POST with csrf
4614// and tz, session-gated, writing nothing.
4615func (s *Server) handleNewAppRegion(w http.ResponseWriter, r *http.Request) {
4616 _, raw, _, _, ok := s.account(w, r)
4617 if !ok {
4618 return
4619 }
4620 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
4621 jsonError(w, http.StatusForbidden, "Cross-site request blocked.")
4622 return
4623 }
4624 files, err := readEdgesSnapshot(r.Context(), s.cfg.Releases)
4625 cands := homeOnlyCandidates()
4626 if err == nil {
4627 cands = s.regionCands(files)
4628 }
4629 g, ok := guessRegion(cands, func(string) bool { return true }, guessInput{TZ: r.FormValue("tz"), IP: s.personIP(r)}, s.cfg.GeoIP)
4630 if !ok {
4631 g = guessResult{"", "default"}
4632 }
4633 writeJSON(w, http.StatusOK, map[string]string{
4634 "code": s.homeRegionOut(g.Code), "name": s.placeOf(g.Code, cands), "source": g.Source,
4635 })
4636}
4637```
4638
4639The CSRF refusal reuses `handleNewApp`'s existing text, which is existing
4640copy and is not new.
4641
4642- [ ] **Step 4: Wire the routes, the GET and the POST**
4643
4644Beside the `apps/new` routes:
4645
4646```go
4647 s.mux.HandleFunc("POST /a/{sqid}/apps/new/region", s.handleNewAppRegion)
4648```
4649
4650In `page`, add `RegionPicker *regionPickerView`. In `handleNewAppForm`,
4651before `render`:
4652
4653```go
4654 p.RegionPicker = s.newAppPicker(r.Context(), s.personIP(r))
4655```
4656
4657In `handleNewApp`, after the CSRF check and the `name` read:
4658
4659```go
4660 rq, regionOK := s.pickedRegion(r.Context(), r)
4661 if !regionOK {
4662 fail(regionCopy.Unavailable)
4663 return
4664 }
4665 outcome, _, held := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner, rq)
4666```
4667
4668In `claimOK`, replace the audit line:
4669
4670```go
4671 detail := "created app " + name
4672 if s.appRegionsOn() {
4673 region, _, _ := s.store.AppRegion(acct.ID, name)
4674 files, _ := readEdgesSnapshot(r.Context(), s.cfg.Releases)
4675 detail = fmt.Sprintf(regionCopy.AuditCreated, name, s.placeOf(region, s.regionCands(files)))
4676 }
4677 s.audit(acct, name, addr, "create", detail)
4678```
4679
4680- [ ] **Step 5: Add the picker to `app_new.html`, leaving the no-picker form byte-identical**
4681
4682Replace the last line of the name `<input>` and the button line (two lines,
4683`app_new.html:13-14`) with the block below. The `{{with}}` opens on the
4684input's own line and `{{end}}` closes on the script's, so with no picker the
4685template emits exactly today's bytes: no blank line, no extra spaces (codex
4686plan-R4 3; spec §10, §16). `TestNewAppPageWithoutPickerIsUnchanged` compares
4687the rendered form with the base tree's own rendering, with the switch off
4688and with the switch on but one eligible region.
4689
4690```html
4691 required pattern="[A-Za-z][A-Za-z0-9-]{0,31}" maxlength="32">{{with .RegionPicker}}
4692 <label for="app-region">{{$.RegionCopy.PickerLabel}}</label>
4693 <select id="app-region" name="region">
4694 <option value="@auto" selected>{{.Auto}}</option>
4695 {{range .Options}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
4696 </select>
4697 <input type="hidden" name="tz" value="">
4698 <p class="small">{{$.RegionCopy.PickerHelp}}</p>
4699 {{if .Attribution}}<p class="small"><a href="https://db-ip.com">{{$.RegionCopy.Attribution}}</a></p>{{end}}
4700 <script>
4701 // Enhancement only: with JS off the server's IP-or-home guess stands.
4702 // Fills the tz hint and relabels ONLY the @auto option; its value stays
4703 // "@auto" so the server still knows the person did not choose.
4704 (function () {
4705 var f = document.currentScript.closest("form"), sel = f.elements.region, tz = "";
4706 var label = {{printf $.RegionCopy.AutoOption "%PLACE%"}};
4707 try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ""; } catch (e) {}
4708 f.elements.tz.value = tz;
4709 if (!tz) return;
4710 var body = new URLSearchParams({ csrf: f.elements.csrf.value, tz: tz });
4711 fetch("{{$.Base}}/apps/new/region", { method: "POST", body: body, credentials: "same-origin" })
4712 .then(function (r) { return r.ok ? r.json() : null; })
4713 .then(function (g) {
4714 if (!g || sel.value !== "@auto") return;
4715 sel.options[0].text = label.replace("%PLACE%", g.name);
4716 })
4717 .catch(function () {});
4718 })();
4719 </script>{{end}}
4720 <button type="submit">Create app</button>
4721```
4722
4723`html/template` renders the `{{printf …}}` in script context as a JS string
4724literal, so the approved C3 shape is used whatever its wording.
4725
4726- [ ] **Step 6: Run the tests to verify they pass**
4727
4728Run: `go test ./internal/console -count=1`
4729Expected: PASS.
4730
4731Then a mutation check: put the block on its own lines instead
4732(` {{with .RegionPicker}}` after the input, ` {{end}}` before the
4733button). `TestNewAppPageWithoutPickerIsUnchanged` must FAIL in both cases
4734(an extra `" \n"`). Restore, and re-run: PASS.
4735
4736- [ ] **Step 7: Commit**
4737
4738```bash
4739git add internal/console/regionpicker.go internal/console/regionpicker_test.go internal/console/server.go internal/console/templates/app_new.html
4740git commit -m "console: new-app region picker with @auto, browser guess route (nearest-region §10, §9.5)
4741
4742Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
4743```
4744
4745---
4746
4747### Task 14: The instance create door: fill, eligibility, the R9 refusal and the response
4748
4749**Files:**
4750- Modify: `internal/console/apiinstances.go`
4751 - `apiRecordView`, at `:535`;
4752 - `:763-799`, where the fill goes;
4753 - the armed gate, at `:827-852`;
4754 - `:1066`, where the response is written.
4755- Create: `internal/console/regionchoices.go` (`instanceChoices`)
4756- Test: `internal/console/regionfill_test.go`
4757
4758**Interfaces:**
4759- Consumes: Tasks 7, 9 (`s.appRegion`) and 10 (`s.regionCands`,
4760 `findCandidate`, `NoKeyProven`).
4761- Produces: `apiRecordView.RegionFrom string`
4762 (`json:"region_from,omitempty"`) and `apiRecordView.RegionName string`
4763 (`json:"region_name,omitempty"`), and
4764 `func (s *Server) instanceChoices(ctx context.Context, allowed []string, cands []regionCandidate, except string) []string`.
4765
4766C16 and C17 list what an EXPLICIT request for the same socket-backed
4767instance would pass now, not what automatic eligibility would pick (codex
4768plan-R4 2): the manifest's allow-list as the door reads it (every code
4769normalized through `homeRegionIn` first, so a home NAME in the allow-list
4770counts as the home only if `""` is allowed), the armed gate's reachable and
4771hosting bars, and R9's refusal. The refused region itself is left out. When
4772nothing passes, the message is C16b or C17b.
4773
4774- [ ] **Step 1: Write the failing tests**
4775
4776Every test that inspects a record asserts the create returned 201 first, so a
4777refusal cannot pass as a non-fill (codex plan-R1 11).
4778
4779```go
4780// internal/console/regionfill_test.go
4781package console
4782
4783import (
4784 "encoding/json"
4785 "errors"
4786 "fmt"
4787 "net/http"
4788 "regexp"
4789 "strings"
4790 "testing"
4791
4792 "github.com/carlosframework/platform/internal/provision"
4793)
4794
4795type fillRec struct {
4796 provision.Record
4797 RegionFrom string `json:"region_from"`
4798 RegionName string `json:"region_name"`
4799}
4800
4801// fillFixture: titogo with an unrestricted shared-pool manifest, a stored
4802// app region, a named home, the deployment's secrets key, and two
4803// eligible boxes. fx.st.db is the Store's own *sql.DB (same package).
4804func fillFixture(t *testing.T, appRegion string) (*consoleFixture, string, string) {
4805 t.Helper()
4806 fx, token := instanceFixture(t, "code-fill-"+appRegion)
4807 fx.srv.cfg.HomeRegion, fx.srv.cfg.SecretsPub = "ie", testKeyPub
4808 m := titoManifest()
4809 m.Pool, m.Regions = "", nil
4810 enableInstances(t, fx, "titogo", m)
4811 if _, err := fx.st.db.Exec(`UPDATE apps SET region = ?, region_source = 'chosen' WHERE app = 'titogo'`, appRegion); err != nil {
4812 t.Fatal(err)
4813 }
4814 setEdges(t, fx, map[string]string{"ie-box": homeBox, "uk-box": ukEligible})
4815 return fx, token, instancesURL(fx, "bab", "titogo")
4816}
4817
4818func create(t *testing.T, u, token, body string) (int, fillRec, string) {
4819 t.Helper()
4820 status, _, out := rawReq(t, http.MethodPost, u, token, body)
4821 var rec fillRec
4822 json.Unmarshal([]byte(out), &rec)
4823 return status, rec, out
4824}
4825
4826func mustCreate(t *testing.T, u, token, body string) fillRec {
4827 t.Helper()
4828 status, rec, out := create(t, u, token, body)
4829 if status != http.StatusCreated {
4830 t.Fatalf("create %s = %d %s, want 201", body, status, out)
4831 }
4832 return rec
4833}
4834
4835func TestFillFromTheAppRegion(t *testing.T) {
4836 _, token, u := fillFixture(t, "uk")
4837 if rec := mustCreate(t, u, token, `{"host":"a.go.tito.io"}`); rec.Region != "uk" || rec.RegionFrom != "app" || rec.RegionName != ukPlace {
4838 t.Fatalf("fill = %+v", rec)
4839 }
4840 if rec := mustCreate(t, u, token, `{"host":"b.go.tito.io","region":"ie"}`); rec.Region != "" || rec.RegionFrom != "request" {
4841 t.Fatalf("explicit home = %+v", rec)
4842 }
4843 for _, body := range []string{
4844 `{"host":"c.go.tito.io","kind":"static"}`,
4845 `{"host":"d.go.tito.io","kind":"service","addrs":["10.0.0.4:8080"]}`,
4846 } {
4847 if rec := mustCreate(t, u, token, body); rec.Region != "" {
4848 t.Errorf("%s was filled: %+v", body, rec)
4849 }
4850 }
4851 status, _, out := create(t, u, token, `{"host":"e.go.tito.io","role":"replica"}`)
4852 if status != http.StatusBadRequest || !strings.Contains(out, "role replica needs a placement") {
4853 t.Errorf("replica without a region = %d %s, want the existing refusal", status, out)
4854 }
4855}
4856
4857func TestFillWithAHomeAppRegionChangesNothing(t *testing.T) {
4858 _, token, u := fillFixture(t, "")
4859 if rec := mustCreate(t, u, token, `{"host":"a.go.tito.io"}`); rec.Region != "" || rec.RegionFrom != "" {
4860 t.Fatalf("home app = %+v", rec)
4861 }
4862}
4863
4864// Precedence with a NON-HOME app default in play: pool and fleet win.
4865func TestFillPrecedence(t *testing.T) {
4866 fx, token, u := fillFixture(t, "uk")
4867 m := titoManifest()
4868 m.Pool, m.Regions = "tito", nil
4869 enableInstances(t, fx, "titogo", m)
4870 if rec := mustCreate(t, u, token, `{"host":"p.go.tito.io"}`); rec.Region != "" || rec.RegionFrom != "" {
4871 t.Fatalf("pooled = %+v, want no fill", rec)
4872 }
4873 m.Pool = ""
4874 enableInstances(t, fx, "titogo", m)
4875 if err := fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme"); err != nil {
4876 t.Fatal(err)
4877 }
4878 if rec := mustCreate(t, u, token, `{"host":"f.go.tito.io"}`); rec.Region != "fleet/acme" || rec.RegionFrom != "fleet" {
4879 t.Fatalf("fleet = %+v", rec)
4880 }
4881}
4882
4883func TestFillStoreErrorIsC20(t *testing.T) {
4884 fx, token, u := fillFixture(t, "uk")
4885 fx.srv.appRegionOf = func(int64, string) (string, string, error) { return "", "", errors.New("db gone") }
4886 status, _, out := create(t, u, token, `{"host":"a.go.tito.io"}`)
4887 if status != http.StatusBadGateway || !strings.Contains(out, msgAppRegionUnreadable) {
4888 t.Fatalf("store error = %d %s, want 502 C20", status, out)
4889 }
4890}
4891
4892// codex R2-2: dormancy keeps explicit requests, never app-derived ones.
4893func TestAppDerivedNeedsPositiveEligibility(t *testing.T) {
4894 fx, token, u := fillFixture(t, "uk")
4895 for _, k := range []string{"control/edges/uk-box.json", "control/edges/ie-box.json"} {
4896 if err := fx.rel.Delete(t.Context(), k); err != nil {
4897 t.Fatal(err)
4898 }
4899 }
4900 mustCreate(t, u, token, `{"host":"x.go.tito.io","region":"uk"}`)
4901 status, _, out := create(t, u, token, `{"host":"y.go.tito.io"}`)
4902 if status != http.StatusBadRequest || !strings.Contains(out, reasonText("absent")) {
4903 t.Fatalf("app-derived uk under dormancy = %d %s, want C16 absent", status, out)
4904 }
4905}
4906
4907func TestAppDerivedEdgesReadFailureIs503(t *testing.T) {
4908 fx, token, u := fillFixture(t, "uk")
4909 fx.srv.cfg.Releases = failingList{fx.rel}
4910 status, _, out := create(t, u, token, `{"host":"y.go.tito.io"}`)
4911 if status != http.StatusServiceUnavailable || !strings.Contains(out, msgAppRegionUnconfirmed("uk")) {
4912 t.Fatalf("edges failure = %d %s, want 503 C28", status, out)
4913 }
4914}
4915
4916func TestAppDerivedRefusals(t *testing.T) {
4917 fx, token, u := fillFixture(t, "uk")
4918 m := titoManifest()
4919 m.Pool, m.Regions = "", []string{"au"}
4920 enableInstances(t, fx, "titogo", m)
4921 // au publishes no box, so nothing the allow-list names can pass: C17b.
4922 if status, _, out := create(t, u, token, `{"host":"y.go.tito.io"}`); status != http.StatusBadRequest ||
4923 !strings.Contains(out, msgAppRegionNotAllowed("uk", nil)) {
4924 t.Fatalf("allow-list = %d %s, want C17b", status, out)
4925 }
4926 m.Regions = nil
4927 enableInstances(t, fx, "titogo", m)
4928 if status, _, out := create(t, u, token, `{"host":"z.go.tito.io","replicas":["uk"]}`); status != http.StatusBadRequest ||
4929 !strings.Contains(out, msgReplicaOverlap("uk")) {
4930 t.Fatalf("replica overlap = %d %s, want C26", status, out)
4931 }
4932 setEdges(t, fx, map[string]string{"uk-box": ukNoKey})
4933 if status, _, out := create(t, u, token, `{"host":"k.go.tito.io"}`); status != http.StatusBadRequest ||
4934 !strings.Contains(out, reasonText("no-secrets-key")) {
4935 t.Fatalf("app-derived keyless uk = %d %s, want C16 no-secrets-key", status, out)
4936 }
4937}
4938
4939// R9's explicit half: refused only when a current writer PROVES the
4940// region keyless, and only for what needs config on the box.
4941func TestExplicitCreateIntoAKeylessRegion(t *testing.T) {
4942 fx, token, u := fillFixture(t, "")
4943 setEdges(t, fx, map[string]string{"uk-box": ukNoKey})
4944 if status, _, out := create(t, u, token, `{"host":"a.go.tito.io","region":"uk"}`); status != http.StatusBadRequest || !strings.Contains(out, msgNoSecretsKey("uk")) {
4945 t.Fatalf("keyless instance = %d %s, want C31", status, out)
4946 }
4947 if status, _, out := create(t, u, token, `{"host":"r.go.tito.io","region":"uk","role":"replica"}`); status != http.StatusBadRequest || !strings.Contains(out, msgNoSecretsKey("uk")) {
4948 t.Fatalf("keyless replica = %d %s, want C31", status, out)
4949 }
4950 mustCreate(t, u, token, `{"host":"s.go.tito.io","region":"uk","kind":"static"}`)
4951 // An addr-backed service proxies to a process the platform never starts,
4952 // so it needs no config on the box either (codex plan-R2 7).
4953 mustCreate(t, u, token, `{"host":"v.go.tito.io","region":"uk","kind":"service","addrs":["10.0.0.4:8080"]}`)
4954 setEdges(t, fx, map[string]string{"uk-box": ukOldWriter})
4955 mustCreate(t, u, token, `{"host":"o.go.tito.io","region":"uk"}`) // unknown keeps today's behaviour
4956 setEdges(t, fx, map[string]string{"uk-box": ukNoKey})
4957 fx.srv.cfg.SecretsPub = ""
4958 mustCreate(t, u, token, `{"host":"n.go.tito.io","region":"uk"}`) // the console cannot judge
4959}
4960
4961func TestFillRunsWithTheSwitchOff(t *testing.T) {
4962 fx, token, u := fillFixture(t, "uk")
4963 fx.srv.cfg.AppRegions = false
4964 if rec := mustCreate(t, u, token, `{"host":"a.go.tito.io"}`); rec.Region != "uk" {
4965 t.Fatalf("switch off = %+v, want the stored uk", rec)
4966 }
4967}
4968
4969// codex plan-R3 6: a successful replica create names its place too, so the
4970// CLI prints C14 for it as for any other placement.
4971func TestReplicaCreateNamesItsPlace(t *testing.T) {
4972 _, token, u := fillFixture(t, "")
4973 rec := mustCreate(t, u, token, `{"host":"r.go.tito.io","region":"uk","role":"replica"}`)
4974 if rec.Role != "replica" || rec.Region != "uk" || rec.RegionName != ukPlace {
4975 t.Fatalf("replica = %+v, want role replica in uk named %q", rec, ukPlace)
4976 }
4977}
4978
4979// persistedRecord is the record's bytes as the bucket holds them, with the
4980// create time blanked (the one field a golden cannot pin).
4981func persistedRecord(t *testing.T, fx *consoleFixture, host string) string {
4982 t.Helper()
4983 raw, _, err := fx.rel.Get(t.Context(), provision.RecordKey("bab", "titogo", host))
4984 if err != nil {
4985 t.Fatal(err)
4986 }
4987 return regexp.MustCompile(`"created_at":"[^"]*"`).ReplaceAllString(string(raw), `"created_at":"T"`)
4988}
4989
4990// codex plan-R3 5, spec §16: the persisted record, byte for byte. A legacy
4991// home create writes exactly what the unmodified door wrote (the golden was
4992// taken from the base tree), and an app-defaulted non-home create differs
4993// only by its region: no steering stamp, nothing else (R8).
4994func TestPersistedRecordsMatchTheirGoldens(t *testing.T) {
4995 const legacy = `{"schema":1,"host":"a.go.tito.io","slug":"a","app":"titogo","account":"bab","channel":"stable","created_at":"T","created_by":"alice@keymail.dev"}`
4996 const appUK = `{"schema":1,"host":"a.go.tito.io","slug":"a","app":"titogo","account":"bab","region":"uk","channel":"stable","created_at":"T","created_by":"alice@keymail.dev"}`
4997 for _, c := range []struct{ appRegion, want string }{{"", legacy}, {"uk", appUK}} {
4998 fx, token, u := fillFixture(t, c.appRegion)
4999 rec := mustCreate(t, u, token, `{"host":"a.go.tito.io"}`)
5000 if rec.Steering != "" {
5001 t.Fatalf("app region %q: the response carries a steering stamp %q", c.appRegion, rec.Steering)
5002 }
5003 if got := persistedRecord(t, fx, "a.go.tito.io"); got != c.want {
5004 t.Fatalf("app region %q: stored\n%s\nwant\n%s", c.appRegion, got, c.want)
5005 }
5006 }
5007}
5008
5009// codex plan-R4 2: C16's and C17's lists name exactly the regions an
5010// explicit create of this same instance would pass: the allow-list, the
5011// armed gate and R9 (not automatic eligibility), never the region refused.
5012// Each advertised region is then submitted, and must be accepted.
5013func TestAppRegionRefusalsAdvertiseOnlyWhatPasses(t *testing.T) {
5014 auEligible := edgeJSON("au-box", "9.9.9.9", "ap-southeast-2", "au", eligibleBox)
5015 for _, c := range []struct {
5016 name string
5017 ukBox string
5018 regions []string
5019 want func(choices []string) string
5020 choices []string
5021 }{
5022 // uk is the app's region, keyless; the home is outside the allow-list.
5023 {"C16", ukNoKey, []string{"uk", "au", "nz"}, func(ch []string) string { return msgAppRegionUnavailable("uk", "no-secrets-key", ch) }, []string{"au"}},
5024 // uk is eligible but outside the allow-list; "ie" there is the
5025 // home's NAME, which the door reads as "" and so refuses.
5026 {"C17", ukEligible, []string{"au", "nz", "ie"}, func(ch []string) string { return msgAppRegionNotAllowed("uk", ch) }, []string{"au"}},
5027 } {
5028 fx, token, u := fillFixture(t, "uk")
5029 setEdges(t, fx, map[string]string{"uk-box": c.ukBox, "au-box": auEligible, "nz-box": nzNoHosting})
5030 m := titoManifest()
5031 m.Pool, m.Regions = "", c.regions
5032 enableInstances(t, fx, "titogo", m)
5033 status, _, out := create(t, u, token, `{"host":"y.go.tito.io"}`)
5034 if want := c.want(c.choices); status != http.StatusBadRequest || !strings.Contains(out, want) {
5035 t.Errorf("%s = %d %s, want %q", c.name, status, out, want)
5036 }
5037 for i, code := range c.choices {
5038 mustCreate(t, u, token, fmt.Sprintf(`{"host":"alt%d.go.tito.io","region":%q}`, i, code))
5039 }
5040 }
5041}
5042```
5043
5044The `legacy` golden is the base tree's own output: the same request (an
5045unrestricted shared-pool manifest, no region, alice's token) against the
5046unmodified door at the branch base, its stored bytes read back with
5047`created_at` blanked. Re-take it the same way if the base moves.
5048
5049- [ ] **Step 2: Run them to verify they fail**
5050
5051Run: `go test ./internal/console -run 'TestFill|TestAppDerived|TestExplicitCreateIntoAKeylessRegion|TestReplicaCreateNamesItsPlace|TestPersistedRecords|TestAppRegionRefusals' -count=1`
5052Expected: FAIL.
5053
5054- [ ] **Step 3: Implement the fill, replacing `apiinstances.go:772`**
5055
5056```go
5057 // The app's region is the default for a create that names none
5058 // (nearest-region spec §7, R2). It runs BEFORE every check an explicit
5059 // region meets, and never for a fleet-placed app, a pooled manifest, a
5060 // replica, or anything but a socket-backed instance.
5061 raw := strings.TrimSpace(req.Region)
5062 regionFrom := ""
5063 if raw != "" {
5064 regionFrom = "request"
5065 }
5066 if raw == "" && placed == "" && m.Pool == "" && strings.TrimSpace(req.Role) == "" &&
5067 (provision.Record{Kind: strings.TrimSpace(req.Kind), Addrs: req.Addrs}).DerivesPaths() {
5068 appRegion, _, err := s.appRegion(acct.ID, app)
5069 if err != nil {
5070 log.Printf("console: api instances create %s/%s: read app region: %v", acct.Sqid, app, err)
5071 jsonError(w, http.StatusBadGateway, msgAppRegionUnreadable)
5072 return
5073 }
5074 if appRegion != "" {
5075 raw, regionFrom = appRegion, "app"
5076 }
5077 }
5078 if placed != "" {
5079 regionFrom = "fleet"
5080 }
5081 region := s.homeRegionIn(raw)
5082 // An APP-DERIVED region needs positive eligibility — the secrets key
5083 // included (R9) — and never rides the dormant gate (codex R2-2).
5084 if regionFrom == "app" {
5085 files, err := readEdgesSnapshot(ctx, s.cfg.Releases)
5086 if err != nil {
5087 log.Printf("console: api instances create %s/%s: read edges: %v", acct.Sqid, app, err)
5088 jsonError(w, http.StatusServiceUnavailable, msgAppRegionUnconfirmed(s.homeRegionOut(region)))
5089 return
5090 }
5091 cands := s.regionCands(files)
5092 reason := "absent"
5093 if c, ok := findCandidate(cands, region); ok {
5094 reason = c.unavailableReason()
5095 }
5096 // Each list names only what an explicit request for this same
5097 // instance would pass (codex plan-R4 2): instanceChoices.
5098 if reason != "" {
5099 jsonError(w, http.StatusBadRequest, msgAppRegionUnavailable(s.homeRegionOut(region), reason, s.instanceChoices(ctx, m.Regions, cands, region)))
5100 return
5101 }
5102 if !instanceRegionAllowed(m.Regions, region) {
5103 jsonError(w, http.StatusBadRequest, msgAppRegionNotAllowed(s.homeRegionOut(region), s.instanceChoices(ctx, m.Regions, cands, region)))
5104 return
5105 }
5106 for _, rep := range req.Replicas {
5107 if s.homeRegionIn(strings.TrimSpace(rep)) == region {
5108 jsonError(w, http.StatusBadRequest, msgReplicaOverlap(s.homeRegionOut(region)))
5109 return
5110 }
5111 }
5112 }
5113```
5114
5115Create `internal/console/regionchoices.go`:
5116
5117```go
5118package console
5119
5120import (
5121 "context"
5122 "sort"
5123
5124 "github.com/carlosframework/platform/internal/provision"
5125)
5126
5127// instanceChoices lists, under their names, the regions an EXPLICIT
5128// request for this same socket-backed instance would pass right now (codex
5129// plan-R4 2): the manifest's allow-list as the door reads it (each code
5130// normalized through homeRegionIn first), the armed gate's reachable and
5131// hosting bars, and R9's refusal. These are the explicit door's rules, not
5132// automatic eligibility. except, the region just refused, is left out.
5133func (s *Server) instanceChoices(ctx context.Context, allowed []string, cands []regionCandidate, except string) []string {
5134 caps, published := placements(ctx, s.cfg.Releases)
5135 codes := map[string]bool{"": true}
5136 for c := range caps {
5137 codes[s.homeRegionIn(c)] = true
5138 }
5139 for _, c := range allowed {
5140 codes[s.homeRegionIn(c)] = true
5141 }
5142 var out []string
5143 for code := range codes {
5144 if code == except || !instanceRegionAllowed(allowed, code) {
5145 continue
5146 }
5147 if code != "" {
5148 if !provision.ValidRegion(code) || (published && !(caps[code].Reachable && caps[code].Hosting)) {
5149 continue
5150 }
5151 if c, ok := findCandidate(cands, code); ok && c.NoKeyProven {
5152 continue
5153 }
5154 }
5155 out = append(out, s.homeRegionOut(code))
5156 }
5157 sort.Strings(out)
5158 return out
5159}
5160```
5161
5162- [ ] **Step 4: Add the R9 explicit refusal to the armed gate**
5163
5164Inside `if placed == "" && region != "" {` (`:827`), after the closing brace
5165of its final `switch` (`:852`):
5166
5167```go
5168 // R9: a socket-backed instance or a replica needs its config,
5169 // secrets and store binding rendered on the box, which only a box
5170 // holding the deployment's secrets key can do. Refused only when a
5171 // CURRENT writer proves the region keyless; unknown keeps today's
5172 // behaviour, so roll order never refuses a working region.
5173 if probe.DerivesPaths() || probe.Role == "replica" {
5174 if files, err := readEdgesSnapshot(ctx, s.cfg.Releases); err == nil {
5175 if c, ok := findCandidate(s.regionCands(files), region); ok && c.NoKeyProven {
5176 jsonError(w, http.StatusBadRequest, msgNoSecretsKey(s.homeRegionOut(region)))
5177 return
5178 }
5179 }
5180 }
5181```
5182
5183- [ ] **Step 5: Fill the response fields**
5184
5185In `apiRecordView`:
5186
5187```go
5188 // RegionFrom and RegionName say how this create chose its region and
5189 // what the place is called (spec §7): facts about this request, not
5190 // part of the record.
5191 RegionFrom string `json:"region_from,omitempty"`
5192 RegionName string `json:"region_name,omitempty"`
5193```
5194
5195At `:1066`, after `out := recordView(rec)`:
5196
5197```go
5198 out.RegionFrom = regionFrom
5199 // Every non-fleet placement is named, replicas included (codex
5200 // plan-R3 6); a fleet-placed record's Region is the fleet itself.
5201 if placed == "" {
5202 out.RegionName = s.placeOf(rec.Region, s.candsOrHome(ctx))
5203 }
5204```
5205
5206- [ ] **Step 6: Run the tests to verify they pass**
5207
5208Run: `go test ./internal/console -count=1`
5209Expected: PASS, including `TestAPIInstanceCreate*` and the
5210placement-door tests.
5211
5212Then a mutation check on the R9 gate's scope. Change
5213`if probe.DerivesPaths() || probe.Role == "replica" {` to `if true {`, and
5214run `go test ./internal/console -run TestExplicitCreateIntoAKeylessRegion -count=1`.
5215It must FAIL on the static or addr-backed create. Revert, and re-run: PASS.
5216
5217Then the list mutation (codex plan-R4 2): in `instanceChoices`, drop the
5218`!instanceRegionAllowed(allowed, code)` condition. Run
5219`go test ./internal/console -run TestAppRegionRefusalsAdvertiseOnlyWhatPasses -count=1`.
5220It must FAIL on both C16 and C17 (the home `ie` is listed). Revert, and
5221re-run: PASS.
5222
5223Then the steering mutation (codex plan-R3 5): right after the
5224`rec := provision.Record{...}` literal, add
5225`if regionFrom == "app" { rec.Steering = "latency" }`, and run
5226`go test ./internal/console -run TestPersistedRecordsMatchTheirGoldens -count=1`.
5227It must FAIL on the app-defaulted record. Revert, and re-run: PASS.
5228
5229- [ ] **Step 7: Commit**
5230
5231```bash
5232git add internal/console/apiinstances.go internal/console/regionchoices.go internal/console/regionfill_test.go
5233git commit -m "console: instance door fills the app region, proves eligibility, refuses keyless regions (nearest-region §7, R9)
5234
5235Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
5236```
5237
5238---
5239
5240### Task 15: `app_default`, place names and the guess endpoint
5241
5242**Files:**
5243- Create: `internal/console/regiondefault.go`
5244- Modify: `internal/console/apiregions.go`
5245- Modify: `internal/console/authcli.go`, beside the `/regions` route at `:586`
5246- Test: `internal/console/regiondefault_test.go`
5247
5248**Interfaces:**
5249- Consumes: Tasks 7, 9, 10, 11 and 12.
5250- Produces:
5251
5252```go
5253type appDefaultView struct {
5254 Region string `json:"region"` // the STORED region, under its name; "" when unknown
5255 Name string `json:"name"`
5256 Source string `json:"source"`
5257 Effective string `json:"effective"` // where a no-region create lands
5258 Available *bool `json:"available,omitempty"`
5259 Reason string `json:"reason,omitempty"`
5260 RegionKnown bool `json:"region_known"` // the stored region was read
5261 PlacementKnown bool `json:"placement_known"` // the manifest and fleet placement were read
5262 InstanceDomain string `json:"instance_domain,omitempty"` // the domain instance hosts take; "" when unknown
5263}
5264func (s *Server) appDefault(ctx context.Context, acct *Account, app string) appDefaultView
5265func (s *Server) platformInstanceDomain(acct *Account) string
5266func (s *Server) handleAPIRegionGuess(w http.ResponseWriter, r *http.Request)
5267```
5268
5269`GET …/regions` entries gain `aws_region` and `name`. The response gains
5270`app_default`.
5271
5272`region_known` and `placement_known` are additive fields this plan adds to
5273spec §9.3's block (codex plan-R2 3). A response is always HTTP 200, so the
5274CLI needs them to tell a confirmed fact from one the console could not read.
5275`deploy --region` needs the stored region, and `canary --region` needs the
5276fleet placement. Each refuses when its fact is unknown (Tasks 21, 22).
5277Spec rev 6.1 records both (R9a): `region_known` is true exactly when the
5278stored region was read, and `placement_known` exactly when the manifest and
5279fleet placement were read; absent knowledge fails closed.
5280
5281`instance_domain` is one more additive field, not in the spec (codex
5282plan-R3 1): the domain instance hosts take. It is the manifest's `domain`
5283when a manifest exists, else the domain the instances-manifest PUT would
5284stamp (`<sqid>.<PLATFORM_APPS_DOMAIN>`), and omitted when the manifest is
5285unreadable or the console has no valid apps domain. `canary --region` needs
5286it to name the canary's host, and read that one record, before its first
5287write.
5288
5289A pooled default's `available` is the allow-list's verdict on the home,
5290because a no-region pooled create lands there (codex plan-R3 2). The
5291`pooled` reason stays, so the Instances tab still shows no line for it.
5292
5293R9a holds in the pooled and fleet branches too (codex plan-R4 1, spec rev
52946.2): with the stored region unread, `effective` stays empty and
5295`available` omitted, and `reason` (`pooled` or `fleet`) is the only
5296placement fact stated. Task 22's canary check therefore needs BOTH flags.
5297
5298- [ ] **Step 1: Write the failing tests**
5299
5300```go
5301// internal/console/regiondefault_test.go
5302package console
5303
5304import (
5305 "context"
5306 "encoding/json"
5307 "errors"
5308 "io"
5309 "net/http"
5310 "strings"
5311 "testing"
5312
5313 "github.com/carlosframework/platform/internal/provision"
5314)
5315
5316func regionsURL(fx *consoleFixture) string { return fx.ts.URL + "/api/cli/apps/bab/titogo/regions" }
5317func guessURL(fx *consoleFixture) string { return regionsURL(fx) + "/guess" }
5318
5319// corruptManifest replaces the manifest with unparsable bytes, under its
5320// current etag (Put with "" is create-only and would not replace it).
5321func corruptManifest(t *testing.T, fx *consoleFixture) {
5322 t.Helper()
5323 key := provision.ManifestKey("bab", "titogo")
5324 _, etag, err := fx.rel.Get(context.Background(), key)
5325 if err != nil {
5326 t.Fatal(err)
5327 }
5328 if _, err := fx.rel.Put(context.Background(), key, []byte("{not json"), etag); err != nil {
5329 t.Fatal(err)
5330 }
5331}
5332
5333func TestRegionsKeepsLegacyFieldsAndAddsAppDefault(t *testing.T) {
5334 fx, token, _ := fillFixture(t, "uk")
5335 status, _, out := rawReq(t, http.MethodGet, regionsURL(fx), token, "")
5336 var res struct {
5337 Default string `json:"default"`
5338 AppDefault appDefaultView `json:"app_default"`
5339 }
5340 if err := json.Unmarshal([]byte(out), &res); err != nil || status != http.StatusOK || res.Default != "ie" {
5341 t.Fatalf("legacy default = %d %s", status, out)
5342 }
5343 if d := res.AppDefault; d.Region != "uk" || d.Effective != "uk" || d.Available == nil || !*d.Available || d.Name != ukPlace {
5344 t.Fatalf("app_default = %+v", d)
5345 }
5346 if !strings.Contains(out, `"aws_region":"eu-west-2"`) || !strings.Contains(out, ukPlace) {
5347 t.Fatalf("entries lack names: %s", out)
5348 }
5349}
5350
5351// Every reason §9.3 names, each from its own condition (codex plan-R1 12).
5352func TestAppDefaultReasons(t *testing.T) {
5353 acct := &Account{ID: OpsAccountID, Sqid: "bab"}
5354 cases := []struct {
5355 name string
5356 setup func(t *testing.T, fx *consoleFixture)
5357 reason string
5358 available *bool
5359 effective string
5360 }{
5361 {"available", func(*testing.T, *consoleFixture) {}, "", bp(true), "uk"},
5362 {"absent", func(t *testing.T, fx *consoleFixture) { fx.rel.Delete(t.Context(), "control/edges/uk-box.json") }, "absent", bp(false), "uk"},
5363 {"not-hosting", func(t *testing.T, fx *consoleFixture) {
5364 setEdges(t, fx, map[string]string{"uk-box": edgeJSON("uk-box", "1.2.3.4", "eu-west-2", "uk", edgeOpts{hib: bp(false), shared: bp(true), inbound: bp(true), fp: testKeyFP})})
5365 }, "not-hosting", bp(false), "uk"},
5366 {"no-secrets-key", func(t *testing.T, fx *consoleFixture) { setEdges(t, fx, map[string]string{"uk-box": ukNoKey}) }, "no-secrets-key", bp(false), "uk"},
5367 {"not-eligible", func(t *testing.T, fx *consoleFixture) {
5368 setEdges(t, fx, map[string]string{"uk-box": edgeJSON("uk-box", "1.2.3.4", "eu-west-2", "uk", edgeOpts{hib: bp(true), shared: bp(false), inbound: bp(true), fp: testKeyFP})})
5369 }, "not-eligible", bp(false), "uk"},
5370 {"not-allowed", func(t *testing.T, fx *consoleFixture) {
5371 m := titoManifest()
5372 m.Pool, m.Regions = "", []string{"au"}
5373 enableInstances(t, fx, "titogo", m)
5374 }, "not-allowed", bp(false), "uk"},
5375 {"pooled", func(t *testing.T, fx *consoleFixture) {
5376 m := titoManifest()
5377 m.Pool, m.Regions = "tito", nil
5378 enableInstances(t, fx, "titogo", m)
5379 }, "pooled", bp(true), "ie"},
5380 {"fleet", func(t *testing.T, fx *consoleFixture) { fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme") }, "fleet", nil, "fleet/acme"},
5381 {"policy-unreadable", corruptManifest, "policy-unreadable", nil, "uk"},
5382 {"edges-unreadable", func(t *testing.T, fx *consoleFixture) { fx.srv.cfg.Releases = failingList{fx.rel} }, "edges-unreadable", nil, "uk"},
5383 }
5384 for _, c := range cases {
5385 fx, _, _ := fillFixture(t, "uk")
5386 c.setup(t, fx)
5387 d := fx.srv.appDefault(t.Context(), acct, "titogo")
5388 okAvail := (c.available == nil && d.Available == nil) || (c.available != nil && d.Available != nil && *c.available == *d.Available)
5389 if d.Reason != c.reason || !okAvail || d.Effective != c.effective || d.Region != "uk" {
5390 t.Errorf("%s: %+v, want reason %q effective %q available %v", c.name, d, c.reason, c.effective, c.available)
5391 }
5392 }
5393}
5394
5395// codex plan-R2 3: a fact the console could not read is never presented as
5396// known: no home-valued region, no non-fleet effective.
5397func TestAppDefaultKnownFlags(t *testing.T) {
5398 acct := &Account{ID: OpsAccountID, Sqid: "bab"}
5399 fx, _, _ := fillFixture(t, "uk")
5400 if d := fx.srv.appDefault(t.Context(), acct, "titogo"); !d.RegionKnown || !d.PlacementKnown || d.Region != "uk" {
5401 t.Fatalf("readable = %+v", d)
5402 }
5403 fx.srv.appRegionOf = func(int64, string) (string, string, error) { return "", "", errors.New("db gone") }
5404 if d := fx.srv.appDefault(t.Context(), acct, "titogo"); d.RegionKnown || !d.PlacementKnown || d.Region != "" || d.Effective != "" || d.Available != nil {
5405 t.Fatalf("unreadable region = %+v, want region unknown and nothing presented as fact", d)
5406 }
5407 fx2, _, _ := fillFixture(t, "uk")
5408 corruptManifest(t, fx2)
5409 if d := fx2.srv.appDefault(t.Context(), acct, "titogo"); !d.RegionKnown || d.PlacementKnown || d.Reason != "policy-unreadable" {
5410 t.Fatalf("unreadable policy = %+v, want placement unknown", d)
5411 }
5412}
5413
5414// codex plan-R3 2: a pooled default is available only when the create door
5415// would take it. A no-region pooled create lands on the home, so the home
5416// must pass the manifest's allow-list.
5417func TestPooledAvailabilityMatchesTheCreateDoor(t *testing.T) {
5418 acct := &Account{ID: OpsAccountID, Sqid: "bab"}
5419 for _, c := range []struct {
5420 name string
5421 regions []string
5422 avail bool
5423 }{
5424 {"no allow-list", nil, true},
5425 {"allow-list without the home", []string{"uk"}, false},
5426 } {
5427 fx, token, u := fillFixture(t, "uk")
5428 m := titoManifest()
5429 m.Pool, m.Regions = "tito", c.regions
5430 enableInstances(t, fx, "titogo", m)
5431 d := fx.srv.appDefault(t.Context(), acct, "titogo")
5432 if d.Reason != "pooled" || d.Effective != "ie" || d.Available == nil || *d.Available != c.avail {
5433 t.Fatalf("%s: app_default = %+v, want pooled, ie, available %v", c.name, d, c.avail)
5434 }
5435 status, _, out := create(t, u, token, `{"host":"p.go.tito.io"}`)
5436 if created := status == http.StatusCreated; created != c.avail {
5437 t.Fatalf("%s: create = %d %s, but app_default said available %v", c.name, status, out, c.avail)
5438 }
5439 }
5440}
5441
5442// codex plan-R3 1: instance_domain is the domain the instances-manifest PUT
5443// stamps on hosts, so the CLI can name a canary's host before any write.
5444func TestAppDefaultInstanceDomain(t *testing.T) {
5445 acct := &Account{ID: OpsAccountID, Sqid: "bab"}
5446 fx, _, _ := fillFixture(t, "uk")
5447 if d := fx.srv.appDefault(t.Context(), acct, "titogo"); d.InstanceDomain != "go.tito.io" {
5448 t.Fatalf("with a manifest = %q, want the manifest's go.tito.io", d.InstanceDomain)
5449 }
5450 corruptManifest(t, fx)
5451 if d := fx.srv.appDefault(t.Context(), acct, "titogo"); d.InstanceDomain != "" {
5452 t.Fatalf("unreadable manifest = %q, want none", d.InstanceDomain)
5453 }
5454 fx2, token := instanceFixture(t, "code-inst-domain")
5455 fx2.srv.cfg.AppsDomain = ""
5456 if d := fx2.srv.appDefault(t.Context(), acct, "titogo"); d.InstanceDomain != "" {
5457 t.Fatalf("no apps domain = %q, want none (the PUT refuses)", d.InstanceDomain)
5458 }
5459 fx2.srv.cfg.AppsDomain = "oncarlos.test"
5460 before := fx2.srv.appDefault(t.Context(), acct, "titogo").InstanceDomain
5461 status, _, out := rawReq(t, http.MethodPut, fx2.ts.URL+"/api/cli/apps/bab/titogo/instances-manifest", token, `{}`)
5462 var m struct {
5463 Domain string `json:"domain"`
5464 }
5465 if err := json.Unmarshal([]byte(out), &m); err != nil || status != http.StatusOK {
5466 t.Fatalf("enable = %d %s", status, out)
5467 }
5468 if before == "" || before != m.Domain {
5469 t.Fatalf("before the PUT instance_domain = %q, the PUT stamped %q", before, m.Domain)
5470 }
5471}
5472
5473// codex plan-R4 1, spec R9a: an unread stored region leaves region, name,
5474// source and effective empty and available omitted, even when a fleet or a
5475// pooled manifest would otherwise decide them. The placement the console
5476// did read stays visible in reason.
5477func TestAppDefaultUnreadRegionHidesEffective(t *testing.T) {
5478 acct := &Account{ID: OpsAccountID, Sqid: "bab"}
5479 for _, c := range []struct {
5480 name string
5481 setup func(t *testing.T, fx *consoleFixture)
5482 reason string
5483 }{
5484 {"pooled", func(t *testing.T, fx *consoleFixture) {
5485 m := titoManifest()
5486 m.Pool, m.Regions = "tito", nil
5487 enableInstances(t, fx, "titogo", m)
5488 }, "pooled"},
5489 {"fleet", func(t *testing.T, fx *consoleFixture) {
5490 if err := fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme"); err != nil {
5491 t.Fatal(err)
5492 }
5493 }, "fleet"},
5494 } {
5495 fx, _, _ := fillFixture(t, "uk")
5496 c.setup(t, fx)
5497 fx.srv.appRegionOf = func(int64, string) (string, string, error) { return "", "", errors.New("db gone") }
5498 d := fx.srv.appDefault(t.Context(), acct, "titogo")
5499 if d.RegionKnown || !d.PlacementKnown || d.Region != "" || d.Name != "" || d.Source != "" ||
5500 d.Effective != "" || d.Available != nil || d.Reason != c.reason {
5501 t.Errorf("%s with an unread region = %+v, want only placement_known and reason %q", c.name, d, c.reason)
5502 }
5503 }
5504}
5505
5506func TestRegionGuessEndpoint(t *testing.T) {
5507 fx, token, _ := fillFixture(t, "")
5508 status, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`)
5509 if status != http.StatusOK || !strings.Contains(out, `"code":"uk"`) || !strings.Contains(out, `"source":"timezone"`) {
5510 t.Fatalf("guess = %d %s", status, out)
5511 }
5512 // Review Focus 4: garbage hints are absent, never an error.
5513 status, _, out = rawReq(t, http.MethodPost, guessURL(fx), token, `{"ip":"not-an-ip","tz":"`+strings.Repeat("a", 10<<10)+`"}`)
5514 if status != http.StatusOK || !strings.Contains(out, `"source":"default"`) {
5515 t.Fatalf("garbage = %d %s", status, out)
5516 }
5517 status, _, out = rawReq(t, http.MethodPost, guessURL(fx), token, `{not json`)
5518 if status != http.StatusOK || !strings.Contains(out, `"source":"default"`) {
5519 t.Fatalf("malformed body = %d %s", status, out)
5520 }
5521 fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme")
5522 if _, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`); !strings.Contains(out, `"guess":null`) {
5523 t.Fatalf("fleet guess = %s (codex R4-1)", out)
5524 }
5525}
5526
5527func TestRegionGuessPooledNullAndUnreadable502(t *testing.T) {
5528 fx, token, _ := fillFixture(t, "")
5529 m := titoManifest()
5530 m.Pool = "tito"
5531 enableInstances(t, fx, "titogo", m)
5532 if _, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`); !strings.Contains(out, `"guess":null`) {
5533 t.Fatalf("pooled guess = %s", out)
5534 }
5535 corruptManifest(t, fx)
5536 if status, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`); status != http.StatusBadGateway || !strings.Contains(out, msgPolicyUnreadable) {
5537 t.Fatalf("unreadable manifest = %d %s, want 502 C27", status, out)
5538 }
5539 fx2, token2, _ := fillFixture(t, "")
5540 fx2.srv.cfg.Registry = t.TempDir() // a directory: the registry will not open
5541 if status, _, out := rawReq(t, http.MethodPost, guessURL(fx2), token2, `{"tz":"Europe/London"}`); status != http.StatusBadGateway || !strings.Contains(out, msgPolicyUnreadable) {
5542 t.Fatalf("unreadable placement = %d %s, want 502 C27", status, out)
5543 }
5544}
5545
5546// codex 8: with a canonical-host redirect configured, a guess POST's hints
5547// appear in no Location, no body, no log and no audit row.
5548func TestRegionGuessHintsNeverLeak(t *testing.T) {
5549 fx, token, _ := fillFixture(t, "")
5550 fx.srv.redirects = map[string]bool{"old.example": true}
5551 var logs strings.Builder
5552 restore := captureLog(&logs)
5553 defer restore()
5554 req, _ := http.NewRequest(http.MethodPost, guessURL(fx), strings.NewReader(`{"tz":"Pacific/Chatham","ip":"203.0.113.77"}`))
5555 req.Host = "old.example"
5556 req.Header.Set("Authorization", "Bearer "+token)
5557 res, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req)
5558 if err != nil {
5559 t.Fatal(err)
5560 }
5561 body, _ := io.ReadAll(res.Body)
5562 res.Body.Close()
5563 everything := res.Header.Get("Location") + string(body)
5564 _, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Pacific/Chatham","ip":"203.0.113.77"}`)
5565 everything += out + logs.String()
5566 events, err := fx.st.Events(OpsAccountID, "titogo", 50)
5567 if err != nil {
5568 t.Fatal(err)
5569 }
5570 for _, e := range events {
5571 everything += e.Detail + e.Message
5572 }
5573 for _, secret := range []string{"Chatham", "203.0.113.77"} {
5574 if strings.Contains(everything, secret) {
5575 t.Fatalf("hint %q leaked: %s", secret, everything)
5576 }
5577 }
5578}
5579```
5580
5581`fx.srv.redirects` is the normalized host set `ServeHTTP` reads
5582(`server.go:723`, `:1306`). `Event` carries both `Detail` and `Message`
5583(`store.go:2066`).
5584
5585- [ ] **Step 2: Run them to verify they fail**
5586
5587Run: `go test ./internal/console -run 'TestRegionsKeeps|TestAppDefault|TestRegionGuess|TestPooledAvailability' -count=1`
5588Expected: FAIL. `TestAppDefaultUnreadRegionHidesEffective` is the R9a case
5589for pooled and fleet apps.
5590
5591- [ ] **Step 3: Implement `regiondefault.go`**
5592
5593```go
5594package console
5595
5596import (
5597 "context"
5598 "encoding/json"
5599 "net"
5600 "net/http"
5601 "strings"
5602
5603 "github.com/carlosframework/platform/internal/provision"
5604)
5605
5606// appDefaultView is §9.3's app_default. region is the STORED region (what
5607// deploy --region compares against); effective is where a no-region create
5608// lands — the fleet itself for a placed app, never the platform home
5609// (codex R2-4). RegionKnown and PlacementKnown say which facts were READ:
5610// an unread fact is left empty, never filled with the home (codex
5611// plan-R2 3; spec R9a). InstanceDomain is the domain instance hosts take,
5612// so canary --region can name its host before any write (codex plan-R3 1).
5613type appDefaultView struct {
5614 Region string `json:"region"`
5615 Name string `json:"name"`
5616 Source string `json:"source"`
5617 Effective string `json:"effective"`
5618 Available *bool `json:"available,omitempty"`
5619 Reason string `json:"reason,omitempty"`
5620 RegionKnown bool `json:"region_known"`
5621 PlacementKnown bool `json:"placement_known"`
5622 InstanceDomain string `json:"instance_domain,omitempty"`
5623}
5624
5625func boolp(b bool) *bool { return &b }
5626
5627func (s *Server) appDefault(ctx context.Context, acct *Account, app string) appDefaultView {
5628 stored, source, rerr := s.appRegion(acct.ID, app)
5629 snap, ferr := readEdgesSnapshot(ctx, s.cfg.Releases)
5630 cands := homeOnlyCandidates()
5631 if ferr == nil {
5632 cands = s.regionCands(snap)
5633 }
5634 pol := s.appPolicy(ctx, acct, app)
5635 v := appDefaultView{RegionKnown: rerr == nil, PlacementKnown: pol.State != policyUnreadable}
5636 switch pol.State {
5637 case policyKnown:
5638 v.InstanceDomain = pol.M.Domain
5639 case policyAbsent:
5640 v.InstanceDomain = s.platformInstanceDomain(acct)
5641 }
5642 if rerr == nil {
5643 v.Region, v.Name, v.Source, v.Effective = s.homeRegionOut(stored), s.placeOf(stored, cands), source, s.homeRegionOut(stored)
5644 }
5645 switch {
5646 case pol.State == policyUnreadable:
5647 v.Reason = "policy-unreadable"
5648 return v
5649 case pol.Placed != "":
5650 // R9a: with the stored region unread, effective stays empty even
5651 // though the fleet is known; reason still says fleet (codex
5652 // plan-R4 1).
5653 v.Reason = "fleet"
5654 if rerr == nil {
5655 v.Effective = pol.Placed
5656 }
5657 return v
5658 case pol.pooled():
5659 // A no-region pooled create lands on the home, so it is available
5660 // only when the home passes the allow-list (codex plan-R3 2). R9a
5661 // again: nothing about effective is stated when the region is unread.
5662 v.Reason = "pooled"
5663 if rerr == nil {
5664 v.Effective, v.Available = s.homeRegionOut(""), boolp(pol.allows(""))
5665 }
5666 return v
5667 case rerr != nil:
5668 // The stored region is unknown, so where a create lands is too.
5669 v.Reason = "policy-unreadable"
5670 return v
5671 case ferr != nil:
5672 v.Reason = "edges-unreadable"
5673 return v
5674 }
5675 reason := "absent"
5676 if c, ok := findCandidate(cands, stored); ok {
5677 reason = c.unavailableReason()
5678 }
5679 if reason == "" && !pol.allows(stored) {
5680 reason = "not-allowed"
5681 }
5682 v.Reason, v.Available = reason, boolp(reason == "")
5683 return v
5684}
5685
5686// platformInstanceDomain is the domain the instances-manifest PUT stamps on
5687// a NEW manifest (handleAPIInstancesEnable): "" when this console has no
5688// valid platform apps domain, which that PUT refuses. With a manifest, its
5689// own domain governs instead.
5690func (s *Server) platformInstanceDomain(acct *Account) string {
5691 if s.cfg.AppsDomain == "" {
5692 return ""
5693 }
5694 d := acct.Sqid + "." + s.cfg.AppsDomain
5695 if !provision.ValidHostShape(d) {
5696 return ""
5697 }
5698 return d
5699}
5700
5701// handleAPIRegionGuess is POST …/regions/guess (spec §9.4). Hints come only
5702// in the body, so no redirect can echo them. A malformed body is read as
5703// no hints; a person's request with no ip uses its own address, a service
5704// principal's never does.
5705func (s *Server) handleAPIRegionGuess(w http.ResponseWriter, r *http.Request) {
5706 _, acct, app, ok := s.instanceGate(w, r)
5707 if !ok {
5708 return
5709 }
5710 var body struct {
5711 TZ string `json:"tz"`
5712 IP string `json:"ip"`
5713 }
5714 _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body)
5715 pol := s.appPolicy(r.Context(), acct, app)
5716 if pol.State == policyUnreadable {
5717 jsonError(w, http.StatusBadGateway, msgPolicyUnreadable)
5718 return
5719 }
5720 if pol.Placed != "" || pol.pooled() {
5721 writeJSON(w, http.StatusOK, map[string]any{"guess": nil})
5722 return
5723 }
5724 ip := net.ParseIP(strings.TrimSpace(body.IP))
5725 if ip == nil {
5726 ip = s.personIP(r)
5727 }
5728 files, err := readEdgesSnapshot(r.Context(), s.cfg.Releases)
5729 cands := homeOnlyCandidates()
5730 if err == nil {
5731 cands = s.regionCands(files)
5732 }
5733 g, ok := guessRegion(cands, pol.allows, guessInput{TZ: body.TZ, IP: ip}, s.cfg.GeoIP)
5734 if !ok {
5735 writeJSON(w, http.StatusOK, map[string]any{"guess": nil})
5736 return
5737 }
5738 writeJSON(w, http.StatusOK, map[string]any{"guess": map[string]string{
5739 "code": s.homeRegionOut(g.Code), "name": s.placeOf(g.Code, cands), "source": g.Source,
5740 }})
5741}
5742```
5743
5744- [ ] **Step 4: Extend `handleAPIRegions` and register the route**
5745
5746In `apiregions.go`, change the gate line to
5747`_, acct, app, ok := s.instanceGate(w, r)`, and build the entries with names:
5748
5749```go
5750 type region struct {
5751 Code string `json:"code"`
5752 Armed bool `json:"armed"`
5753 AWSRegion string `json:"aws_region,omitempty"`
5754 Name string `json:"name,omitempty"`
5755 }
5756 set, _ := armedPlacements(r.Context(), s.cfg.Releases)
5757 cands := s.candsOrHome(r.Context())
5758 named := func(code string) region {
5759 c, _ := findCandidate(cands, s.homeRegionIn(code))
5760 return region{Code: code, AWSRegion: c.AWSRegion, Name: s.placeOf(s.homeRegionIn(code), cands)}
5761 }
5762 out := make([]region, 0, len(set)+1)
5763 if s.cfg.HomeRegion != "" {
5764 h := named(s.cfg.HomeRegion)
5765 h.Armed = true
5766 out = append(out, h)
5767 delete(set, s.cfg.HomeRegion)
5768 }
5769 for code, armed := range set {
5770 e := named(code)
5771 e.Armed = armed
5772 out = append(out, e)
5773 }
5774 sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code })
5775 writeJSON(w, http.StatusOK, map[string]any{
5776 "regions": out, "default": s.cfg.HomeRegion,
5777 "app_default": s.appDefault(r.Context(), acct, app),
5778 })
5779```
5780
5781In `authcli.go`:
5782
5783```go
5784 // The per-caller region guess (nearest-region spec §9.4): POST, so the
5785 // hints never ride a URL a redirect could echo.
5786 s.cliRoute("POST /api/cli/apps/{account}/{app}/regions/guess", svcrole.Allow(svcrole.Instance), s.handleAPIRegionGuess)
5787```
5788
5789Then add the new route to `exemptFromLegacyPin` in
5790`internal/console/svcroutes_test.go`, beside `GET …/regions`.
5791`TestInstanceRoleMatchesTodaysAppTokenReach` pins every `instance`-role route
5792to what an app token reached before service principals, and the guess route
5793did not exist then:
5794
5795```go
5796 // The region guess (nearest-region spec §9.4) stands on GET .../regions'
5797 // footing: the same deployment topology, filtered by the app's own
5798 // policy, for a hint the caller supplies. It did not exist before
5799 // service principals, and titogo's per-instance picker is its consumer.
5800 "POST /api/cli/apps/{account}/{app}/regions/guess": true,
5801```
5802
5803- [ ] **Step 5: Run the tests to verify they pass**
5804
5805Run: `go test ./internal/console -count=1`
5806Expected: PASS, including the existing `/regions` tests, whose `regions` and
5807`default` must be unchanged, and `TestInstanceRoleMatchesTodaysAppTokenReach`.
5808
5809- [ ] **Step 6: Commit**
5810
5811```bash
5812git add internal/console/regiondefault.go internal/console/regiondefault_test.go internal/console/apiregions.go internal/console/authcli.go internal/console/svcroutes_test.go
5813git commit -m "console: app_default with every reason, place names, POST regions/guess (nearest-region §9.3, §9.4)
5814
5815Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
5816```
5817
5818---
5819
5820### Task 16: The Instances tab lines
5821
5822**Files:**
5823- Modify: `internal/console/server.go`
5824 - the `page` struct;
5825 - `handleAppInstances`, at `:2933-2965`.
5826- Modify: `internal/console/templates/app_instances.html:60-78`
5827- Test: `internal/console/regiondefault_test.go` (append)
5828
5829**Interfaces:**
5830- Consumes: `s.appDefault` (Task 15) and `regionCopy` (Task 6).
5831- Produces: `page.RegionLine string` and `page.RegionLineWarn bool`.
5832
5833- [ ] **Step 1: Write the failing test**
5834
5835```go
5836// C7 only when the default is CONFIRMED available; C8 whenever it is
5837// confirmed unavailable; nothing for fleet, pooled, or a default that
5838// could not be read (codex plan-R1 14). Assertions compare the HTML-escaped
5839// copy, both ways (codex plan-R1 8).
5840func TestInstancesTabRegionLines(t *testing.T) {
5841 starts := html.EscapeString(fmt.Sprintf(regionCopy.StartsIn, ukPlace))
5842 cantUK := html.EscapeString(fmt.Sprintf(regionCopy.CantStart, "uk"))
5843 page := func(fx *consoleFixture) string {
5844 _, _, body := get(t, signInPaul(t, fx), fx.u("/apps/titogo/instances"))
5845 return body
5846 }
5847 anyLine := func(body string) bool { return strings.Contains(body, `id="app-region-line"`) }
5848
5849 fx, _, _ := fillFixture(t, "uk")
5850 fx.srv.cfg.AppRegions = true
5851 if body := page(fx); !strings.Contains(body, starts) {
5852 t.Fatal("no C7 line for an available region")
5853 }
5854 fx.rel.Delete(t.Context(), "control/edges/uk-box.json")
5855 // No box publishes uk, so its place falls back to the bare code (§14).
5856 if body := page(fx); !strings.Contains(body, cantUK) || strings.Contains(body, starts) {
5857 t.Fatal("want C8, not C7, when the region is absent (codex 7)")
5858 }
5859
5860 for _, setup := range []func(*consoleFixture){
5861 func(fx *consoleFixture) { fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme") },
5862 func(fx *consoleFixture) {
5863 m := titoManifest()
5864 m.Pool = "tito"
5865 enableInstances(t, fx, "titogo", m)
5866 },
5867 func(fx *consoleFixture) { corruptManifest(t, fx) },
5868 func(fx *consoleFixture) { fx.srv.cfg.Releases = failingList{fx.rel} },
5869 } {
5870 fx, _, _ := fillFixture(t, "uk")
5871 setup(fx)
5872 if anyLine(page(fx)) {
5873 t.Error("a fleet, pooled or unreadable default showed a region line")
5874 }
5875 }
5876}
5877```
5878
5879Add `"fmt"` and `"html"` to the file's imports. `anyLine` looks for the
5880line's element id, so it does not depend on the copy's wording.
5881
5882- [ ] **Step 2: Run it to verify it fails**
5883
5884Run: `go test ./internal/console -run TestInstancesTabRegionLines -count=1`
5885Expected: FAIL.
5886
5887- [ ] **Step 3: Implement it**
5888
5889In `page`:
5890
5891```go
5892 // RegionLine is the Instances tab's app-region note (C7 or C8, spec
5893 // §10); "" shows nothing. RegionLineWarn marks C8.
5894 RegionLine string
5895 RegionLineWarn bool
5896```
5897
5898In `handleAppInstances`, after the placement section:
5899
5900```go
5901 if d := s.appDefault(r.Context(), acct, p.AppName); d.Available != nil && d.Reason != "pooled" {
5902 switch {
5903 case !*d.Available:
5904 p.RegionLine, p.RegionLineWarn = fmt.Sprintf(regionCopy.CantStart, d.Name), true
5905 case d.Region != s.cfg.HomeRegion:
5906 p.RegionLine = fmt.Sprintf(regionCopy.StartsIn, d.Name)
5907 default:
5908 if files, err := readEdgesSnapshot(r.Context(), s.cfg.Releases); err == nil && eligibleCount(s.regionCands(files)) >= 2 {
5909 p.RegionLine = fmt.Sprintf(regionCopy.StartsIn, d.Name)
5910 }
5911 }
5912 }
5913```
5914
5915`Available` is nil for fleet, policy-unreadable and edges-unreadable, so
5916those show nothing. Pooled is excluded by name.
5917
5918In `app_instances.html`, directly after `<h2>Placement</h2>`:
5919
5920```html
5921{{if .RegionLine}}<p id="app-region-line"{{if .RegionLineWarn}} class="banner error"{{end}}>{{.RegionLine}}</p>{{end}}
5922```
5923
5924- [ ] **Step 4: Run the tests to verify they pass**
5925
5926Run: `go test ./internal/console -count=1`
5927Expected: PASS.
5928
5929- [ ] **Step 5: Commit**
5930
5931```bash
5932git add internal/console/server.go internal/console/templates/app_instances.html internal/console/regiondefault_test.go
5933git commit -m "console: Instances tab names where new instances start, only when confirmed (nearest-region §10)
5934
5935Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
5936```
5937
5938---
5939
5940### Task 17: Roll-forward discrepancy log
5941
5942**Files:**
5943- Create: `internal/console/regiondrift.go`
5944- Modify: `cmd/console/main.go` (call once after `NewServer`)
5945- Test: `internal/console/regiondrift_test.go`
5946
5947**Interfaces:**
5948- Consumes: `Store.NonHomeAppRegions` (Task 8), `provision.ListRecords`, and
5949 `msgPlacementDiscrepancy` (Task 7).
5950- Produces: `func (s *Server) LogPlacementDiscrepancies(ctx context.Context)`.
5951
5952- [ ] **Step 1: Write the failing test**
5953
5954```go
5955// internal/console/regiondrift_test.go
5956package console
5957
5958import (
5959 "context"
5960 "strings"
5961 "testing"
5962 "time"
5963
5964 "github.com/carlosframework/platform/internal/provision"
5965 "github.com/carlosframework/platform/internal/release"
5966)
5967
5968// codex R2-6: shapes that never inherit the app default are not listed;
5969// a no-region socket-backed instance created after the app is.
5970func TestPlacementDiscrepancyLog(t *testing.T) {
5971 fx := newConsoleFixture(t)
5972 fx.srv.cfg.HomeRegion = "ie"
5973 created := time.Now().Add(-time.Hour)
5974 fx.st.CreateAppInRegion(OpsAccountID, "blog", "p@k.dev", "uk", "chosen", created)
5975 ctx := context.Background()
5976 put := func(r provision.Record) {
5977 r.Schema, r.App, r.Account, r.Channel, r.CreatedAt = 1, "blog", release.OpsAccount, "stable", time.Now()
5978 if err := provision.CreateRecord(ctx, fx.rel, r); err != nil {
5979 t.Fatal(err)
5980 }
5981 }
5982 put(provision.Record{Host: "blog.go.tito.io", Kind: "static"})
5983 put(provision.Record{Host: "svc.go.tito.io", Kind: "service", Addrs: []string{"10.0.0.4:8080"}})
5984 put(provision.Record{Host: "r.go.tito.io", Region: "au", Role: "replica"})
5985 put(provision.Record{Host: "pooled.go.tito.io", Pool: "tito"})
5986 put(provision.Record{Host: "uk.go.tito.io", Region: "uk"})
5987 put(provision.Record{Host: "drift.go.tito.io"})
5988 var logs strings.Builder
5989 restore := captureLog(&logs)
5990 fx.srv.LogPlacementDiscrepancies(ctx)
5991 restore()
5992 out := logs.String()
5993 if !strings.Contains(out, msgPlacementDiscrepancy("drift.go.tito.io", "blog", "uk")) {
5994 t.Fatalf("drift not listed: %s", out)
5995 }
5996 for _, quiet := range []string{"blog.go.tito.io", "svc.go.tito.io", "r.go.tito.io", "pooled.go.tito.io", "uk.go.tito.io"} {
5997 if strings.Contains(out, quiet+" of") {
5998 t.Errorf("%s listed", quiet)
5999 }
6000 }
6001}
6002```
6003
6004`provision.CreateRecord` is a plain create-only write (`provision.go:820`),
6005so these fixture records need no manifest.
6006
6007- [ ] **Step 2: Run it to verify it fails**
6008
6009Run: `go test ./internal/console -run TestPlacementDiscrepancyLog -count=1`
6010Expected: FAIL.
6011
6012- [ ] **Step 3: Implement `regiondrift.go`**
6013
6014```go
6015package console
6016
6017import (
6018 "context"
6019 "log"
6020
6021 "github.com/carlosframework/platform/internal/provision"
6022)
6023
6024// discrepancyScanCap bounds one app's record listing. ListRecords treats
6025// max=0 as "list nothing", so the cap must be explicit.
6026const discrepancyScanCap = 10000
6027
6028// LogPlacementDiscrepancies is spec §5.7 step 4, run once at console
6029// start. For each app holding a non-home region, it lists records with an
6030// empty region created after the app — the shape a console rolled back
6031// below the floor would have written. Shapes that never inherit the app
6032// default are skipped (static, addr-backed, replica, pooled, fleet). A
6033// deliberate home override looks the same and the record keeps no
6034// provenance, so each line is a CANDIDATE for review, worded as one (C29).
6035func (s *Server) LogPlacementDiscrepancies(ctx context.Context) {
6036 if s.cfg.Releases == nil {
6037 return
6038 }
6039 apps, err := s.store.NonHomeAppRegions()
6040 if err != nil {
6041 log.Printf("console: placement discrepancy check: %v", err)
6042 return
6043 }
6044 for _, a := range apps {
6045 recs, truncated, err := provision.ListRecords(ctx, s.cfg.Releases, a.Sqid, a.App, discrepancyScanCap)
6046 if err != nil {
6047 log.Printf("console: placement discrepancy check %s/%s: %v", a.Sqid, a.App, err)
6048 continue
6049 }
6050 if truncated {
6051 log.Printf("console: placement discrepancy check %s/%s: only the first %d records were checked", a.Sqid, a.App, discrepancyScanCap)
6052 }
6053 for _, r := range recs {
6054 if r.Region != "" || r.Role != "" || r.Pool != "" || !r.DerivesPaths() || r.CreatedAt.Before(a.CreatedAt) {
6055 continue
6056 }
6057 log.Print("console: " + msgPlacementDiscrepancy(r.Host, a.App, a.Region))
6058 }
6059 }
6060}
6061```
6062
6063- [ ] **Step 4: Call it at startup**
6064
6065In `cmd/console/main.go`, after `srv` is built and before serving:
6066
6067```go
6068 // Spec §5.7: after any roll, list possible placement discrepancies for
6069 // operator review. Read-only; never blocks startup.
6070 go srv.LogPlacementDiscrepancies(context.Background())
6071```
6072
6073- [ ] **Step 5: Run the tests to verify they pass**
6074
6075Run: `go test ./internal/console ./cmd/console -count=1`
6076Expected: PASS.
6077
6078- [ ] **Step 6: Commit**
6079
6080```bash
6081git add internal/console/regiondrift.go internal/console/regiondrift_test.go cmd/console/main.go
6082git commit -m "console: list possible placement discrepancies on start (nearest-region §5.7)
6083
6084Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
6085```
6086
6087---
6088
6089### Task 18: CLI copy (C9 to C15, C13a, C13b, C15b, C15c, C22, C25, C30, C30b, C30c, from the record)
6090
6091**Files:**
6092- Create: `region_copy.go` (root, package `main`)
6093- Test: `region_copy_test.go`
6094
6095**Interfaces:**
6096- Consumes: the record's C9 to C15, C13a, C13b, C15b, C15c, C22a to C22d, C25, C30,
6097 C30b and C30c (Task 0).
6098- Produces:
6099
6100```go
6101func regionCreatedLine(place, source string) string // C9 to C12
6102const regionNotRecordedOldConsole // C13a
6103func regionNotAppliedSwitchOff(place string) string // C13b
6104func instanceCreatedSuffix(channel, place string) string // C14
6105func deployRegionMismatch(app, place string) string // C15
6106func deployRegionUnreadable(app string, err error) string // C15b
6107func deployRegionUnknown(app string) string // C15c
6108func canaryRegionMismatch(host, place string) string // C25
6109func canaryRegionOnFleet(app, fleet string) string // C30
6110func canaryFleetUnknown(app string) string // C30b
6111func canaryRegionUnconfirmed(name string) string // C30c
6112const flagRegionAppsCreate, flagRegionDeploy, flagRegionCanary, flagRegionInstancesCreate // C22a to C22d
6113```
6114
6115- [ ] **Step 1: Write the failing test**
6116
6117```go
6118// region_copy_test.go
6119package main
6120
6121import (
6122 "errors"
6123 "strings"
6124 "testing"
6125)
6126
6127func TestRegionCLICopy(t *testing.T) {
6128 lines := map[string]string{}
6129 for _, src := range []string{"timezone", "ip", "default", "chosen"} {
6130 lines[src] = regionCreatedLine("P", src)
6131 }
6132 if len(map[string]bool{lines["timezone"]: true, lines["ip"]: true, lines["default"]: true, lines["chosen"]: true}) != 4 {
6133 t.Fatalf("each source needs its own line: %v", lines)
6134 }
6135 if regionCreatedLine("P", "") != lines["default"] {
6136 t.Fatal("an unknown source must read as the default")
6137 }
6138 all := []string{
6139 regionNotRecordedOldConsole, regionNotAppliedSwitchOff("P"),
6140 instanceCreatedSuffix("stable", "P"), deployRegionMismatch("blog", "P"),
6141 deployRegionUnreadable("blog", errors.New("timeout")), deployRegionUnknown("blog"),
6142 canaryRegionMismatch("c.go.tito.io", "P"), canaryRegionOnFleet("blog", "acme"),
6143 canaryFleetUnknown("blog"), canaryRegionUnconfirmed("c"),
6144 flagRegionAppsCreate, flagRegionDeploy, flagRegionCanary, flagRegionInstancesCreate,
6145 }
6146 for _, s := range append(all, lines["timezone"], lines["ip"], lines["default"], lines["chosen"]) {
6147 if s == "" || strings.Contains(s, "—") || strings.Contains(s, "%!") {
6148 t.Errorf("bad copy %q", s)
6149 }
6150 }
6151}
6152```
6153
6154- [ ] **Step 2: Run it to verify it fails**
6155
6156Run: `go test . -run TestRegionCLICopy -count=1`
6157Expected: FAIL.
6158
6159- [ ] **Step 3: Write the strings from the record**
6160
6161The drafts are shown. Write each from its record entry verbatim.
6162
6163```go
6164package main
6165
6166import "fmt"
6167
6168// The nearest-region CLI strings (spec §19 and this plan's additions), as
6169// approved in the nearest-region copy record. Change none of them without a
6170// new copy review.
6171
6172// C9 to C12, by source word; anything else reads as the default.
6173func regionCreatedLine(place, source string) string {
6174 switch source {
6175 case "timezone":
6176 return fmt.Sprintf("Region: %s (from your timezone). Use --region to choose another.", place)
6177 case "ip":
6178 return fmt.Sprintf("Region: %s (from your network location). Use --region to choose another.", place)
6179 case "chosen":
6180 return fmt.Sprintf("Region: %s.", place)
6181 }
6182 return fmt.Sprintf("Region: %s (the platform default). Use --region to choose another.", place)
6183}
6184
6185// C13a
6186const regionNotRecordedOldConsole = "This console does not record an app region yet, so --region was not applied."
6187
6188// C13b
6189func regionNotAppliedSwitchOff(place string) string {
6190 return fmt.Sprintf("This console is not assigning app regions yet, so --region was not applied. This app's instances start in %s.", place)
6191}
6192
6193// C14: the parenthesis content of "created instance {host} (...)".
6194func instanceCreatedSuffix(channel, place string) string {
6195 return fmt.Sprintf("channel %s, %s", channel, place)
6196}
6197
6198// C15
6199func deployRegionMismatch(app, place string) string {
6200 return fmt.Sprintf("%s already runs in %s. --region only applies when deploy creates an app. To place one instance elsewhere, use carlos instances create --region.", app, place)
6201}
6202
6203// C15b: deploy STOPS when it cannot read the app's region.
6204func deployRegionUnreadable(app string, err error) string {
6205 return fmt.Sprintf("could not read %s's region (%v), so deploy stopped before shipping anything. Try again, or run without --region.", app, err)
6206}
6207
6208// C15c: the console answered, but could not read the app's region.
6209func deployRegionUnknown(app string) string {
6210 return fmt.Sprintf("the console could not read %s's region, so deploy stopped before shipping anything. Try again, or run without --region.", app)
6211}
6212
6213// C25
6214func canaryRegionMismatch(host, place string) string {
6215 return fmt.Sprintf("canary %s already runs in %s. --region applies only when canary creates the host. Run carlos canary remove first to start it somewhere else.", host, place)
6216}
6217
6218// C30
6219func canaryRegionOnFleet(app, fleet string) string {
6220 return fmt.Sprintf("%s runs on your fleet %s, which decides where every instance runs, so --region does not apply. Run the command without --region.", app, fleet)
6221}
6222
6223// C30b: an older console cannot say whether a fleet governs.
6224func canaryFleetUnknown(app string) string {
6225 return fmt.Sprintf("this console can't say where %s's instances run, so --region can't be checked. Update the console, or run the command without --region.", app)
6226}
6227
6228// C30c: the canary's own record could not be read, so whether it already
6229// runs elsewhere is unknown.
6230func canaryRegionUnconfirmed(name string) string {
6231 return fmt.Sprintf("could not confirm where canary %s runs, so nothing was changed. Try again, or run the command without --region.", name)
6232}
6233
6234// C22a to C22d: flag help.
6235const (
6236 flagRegionAppsCreate = "region this app's instances start in (default: the region nearest you)"
6237 flagRegionDeploy = "region for the app this deploy creates (default: the region nearest you)"
6238 flagRegionCanary = "region for the canary instance (default: the app's region)"
6239 flagRegionInstancesCreate = "region for this instance (default: the app's region, subject to the manifest's allow-list)"
6240)
6241```
6242
6243- [ ] **Step 4: Run it to verify it passes**
6244
6245Run: `go test . -run TestRegionCLICopy -count=1`
6246Expected: PASS.
6247
6248- [ ] **Step 5: Commit**
6249
6250```bash
6251git add region_copy.go region_copy_test.go
6252git commit -m "cli: region copy, as approved
6253
6254Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
6255```
6256
6257---
6258
6259### Task 19: Timezone detection
6260
6261**Files:**
6262- Create: `timezone.go` (root)
6263- Test: `timezone_test.go`
6264
6265**Interfaces:**
6266- Produces:
6267 - `func localTimezone(getenv func(string) string, readlink func(string) (string, error), readFile func(string) ([]byte, error)) string`;
6268 - `func detectTimezone() string`, which wraps `localTimezone` with the `os`
6269 functions.
6270
6271- [ ] **Step 1: Write the failing tests**
6272
6273```go
6274// timezone_test.go
6275package main
6276
6277import (
6278 "errors"
6279 "testing"
6280)
6281
6282func TestLocalTimezone(t *testing.T) {
6283 noLink := func(string) (string, error) { return "", errors.New("no") }
6284 noFile := func(string) ([]byte, error) { return nil, errors.New("no") }
6285 env := func(v string) func(string) string {
6286 return func(k string) string {
6287 if k == "TZ" {
6288 return v
6289 }
6290 return ""
6291 }
6292 }
6293 cases := []struct {
6294 name string
6295 getenv func(string) string
6296 readlink func(string) (string, error)
6297 readFile func(string) ([]byte, error)
6298 want string
6299 }{
6300 {"TZ plain", env("Europe/London"), noLink, noFile, "Europe/London"},
6301 {"TZ colon", env(":Europe/London"), noLink, noFile, "Europe/London"},
6302 {"TZ path", env("/usr/share/zoneinfo/Asia/Kolkata"), noLink, noFile, "Asia/Kolkata"},
6303 {"localtime link (linux)", env(""), func(string) (string, error) { return "/usr/share/zoneinfo/Europe/Dublin", nil }, noFile, "Europe/Dublin"},
6304 {"localtime link (macOS)", env(""), func(string) (string, error) { return "/var/db/timezone/zoneinfo/Australia/Sydney", nil }, noFile, "Australia/Sydney"},
6305 {"etc/timezone", env(""), noLink, func(string) ([]byte, error) { return []byte("Europe/Paris\n"), nil }, "Europe/Paris"},
6306 // Review Focus 1: real-machine noise is a clean name or nothing.
6307 {"etc/timezone padded", env(""), noLink, func(string) ([]byte, error) { return []byte(" America/New_York \r\n# trailing\n"), nil }, "America/New_York"},
6308 {"TZ bare colon", env(":"), noLink, noFile, ""},
6309 {"TZ localtime", env("localtime"), noLink, noFile, ""},
6310 {"TZ posixrules path", env("/usr/share/zoneinfo/posixrules"), noLink, noFile, ""},
6311 {"TZ POSIX rule string", env("EST5EDT,M3.2.0,M11.1.0"), noLink, noFile, ""},
6312 {"nothing", env(""), noLink, noFile, ""},
6313 }
6314 for _, c := range cases {
6315 if got := localTimezone(c.getenv, c.readlink, c.readFile); got != c.want {
6316 t.Errorf("%s: %q, want %q", c.name, got, c.want)
6317 }
6318 }
6319}
6320```
6321
6322- [ ] **Step 2: Run them to verify they fail**
6323
6324Run: `go test . -run TestLocalTimezone -count=1`
6325Expected: FAIL.
6326
6327- [ ] **Step 3: Implement `timezone.go`**
6328
6329```go
6330package main
6331
6332import (
6333 "os"
6334 "regexp"
6335 "strings"
6336)
6337
6338// tzName is the shape of an IANA zone name the console will accept (spec
6339// §6.2 step 1). Anything else is not sent: the console then falls back to
6340// the request's IP, then the home.
6341var tzName = regexp.MustCompile(`^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+){0,2}$`)
6342
6343var notAZone = map[string]bool{"localtime": true, "posixrules": true, "Local": true}
6344
6345// localTimezone finds this machine's IANA zone name, portably (no build
6346// tags; the 20-cross gate builds it for darwin, windows and linux-arm64):
6347// $TZ, else the /etc/localtime symlink target, else /etc/timezone, else "".
6348// Windows has none of these, so only a valid $TZ is ever sent there.
6349func localTimezone(getenv func(string) string, readlink func(string) (string, error), readFile func(string) ([]byte, error)) string {
6350 if tz := clean(strings.TrimPrefix(strings.TrimSpace(getenv("TZ")), ":")); tz != "" {
6351 return tz
6352 }
6353 if target, err := readlink("/etc/localtime"); err == nil {
6354 if tz := clean(target); tz != "" {
6355 return tz
6356 }
6357 }
6358 if b, err := readFile("/etc/timezone"); err == nil {
6359 line, _, _ := strings.Cut(string(b), "\n")
6360 return clean(line)
6361 }
6362 return ""
6363}
6364
6365func clean(s string) string {
6366 s = strings.TrimSpace(s)
6367 if _, after, ok := strings.Cut(s, "zoneinfo/"); ok {
6368 s = after
6369 }
6370 if s == "" || strings.HasPrefix(s, "/") || notAZone[s] || len(s) > 64 || !tzName.MatchString(s) {
6371 return ""
6372 }
6373 return s
6374}
6375
6376func detectTimezone() string { return localTimezone(os.Getenv, os.Readlink, os.ReadFile) }
6377```
6378
6379- [ ] **Step 4: Run them to verify they pass, and cross-compile**
6380
6381Run:
6382
6383```bash
6384go test . -run TestLocalTimezone -count=1
6385fail=0
6386for t in darwin/arm64 darwin/amd64 linux/arm64 windows/amd64 windows/arm64; do
6387 CGO_ENABLED=0 GOOS=${t%/*} GOARCH=${t#*/} go build ./... || { echo "FAILED $t"; fail=1; }
6388done
6389[ "$fail" = 0 ] || exit 1 # stops this block before any commit below it
6390```
6391
6392Expected: PASS, and no `FAILED` line.
6393
6394- [ ] **Step 5: Commit**
6395
6396```bash
6397git add timezone.go timezone_test.go
6398git commit -m "cli: portable timezone detection (nearest-region §11)
6399
6400Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
6401```
6402
6403---
6404
6405### Task 20: Create-app wire, `apps create --region` and the onboarding line
6406
6407**Files:**
6408- Modify: `apimode.go:958-1015` (`apiCreateAppResult`, `createApp`)
6409- Modify: `apps.go:442-510` (`runAppsCreate`)
6410- Modify: `deploy.go:513-541` (`offerCreateApp`), `deploy.go:36-60` (`runDeploy` flags)
6411- Modify: `apps_test.go:299-321` and every other `createApp(` call site (`grep -n 'createApp(' *.go`)
6412- Test: `apps_region_test.go`
6413
6414**Interfaces:**
6415- Consumes: Tasks 18 and 19.
6416- Produces:
6417 - `func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string, controlPlane bool, buildProbe, region, timezone string) (apiCreateAppResult, error)`;
6418 - new result fields `Region`, `RegionName`, `RegionSource string` and
6419 `RegionsAvailable int`;
6420 - `func printAppRegion(w io.Writer, res apiCreateAppResult, asked string)`.
6421
6422- [ ] **Step 1: Write the failing tests**
6423
6424```go
6425// apps_region_test.go
6426package main
6427
6428import (
6429 "bytes"
6430 "context"
6431 "encoding/json"
6432 "net/http"
6433 "net/http/httptest"
6434 "testing"
6435)
6436
6437func TestCreateAppSendsRegionAndTimezone(t *testing.T) {
6438 var got map[string]any
6439 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6440 json.NewDecoder(r.Body).Decode(&got)
6441 json.NewEncoder(w).Encode(map[string]any{"account": "a", "app": "blog",
6442 "region": "uk", "region_name": "UK · London", "region_source": "timezone", "regions_available": 3})
6443 }))
6444 defer ts.Close()
6445 res, err := newTestSession(ts.URL).createApp(context.Background(), apiAcct{Seg: "a"}, "blog", "", false, "", "", "Europe/London")
6446 if err != nil {
6447 t.Fatal(err)
6448 }
6449 if got["timezone"] != "Europe/London" {
6450 t.Fatalf("body = %v", got)
6451 }
6452 if _, sent := got["region"]; sent {
6453 t.Fatal("region sent without --region")
6454 }
6455 if res.RegionName != "UK · London" || res.RegionsAvailable != 3 {
6456 t.Fatalf("result = %+v", res)
6457 }
6458}
6459
6460func TestPrintAppRegion(t *testing.T) {
6461 cases := []struct {
6462 res apiCreateAppResult
6463 asked string
6464 want string
6465 }{
6466 {apiCreateAppResult{Region: "uk", RegionName: "UK · London", RegionSource: "timezone", RegionsAvailable: 2}, "", regionCreatedLine("UK · London", "timezone") + "\n"},
6467 {apiCreateAppResult{Region: "ie", RegionName: "Ireland · Dublin", RegionSource: "default", RegionsAvailable: 1}, "", ""},
6468 {apiCreateAppResult{}, "uk", regionNotRecordedOldConsole + "\n"},
6469 {apiCreateAppResult{Region: "ie", RegionName: "Ireland · Dublin", RegionSource: "default", RegionsAvailable: 2}, "uk", regionNotAppliedSwitchOff("Ireland · Dublin") + "\n"},
6470 {apiCreateAppResult{}, "", ""},
6471 }
6472 for i, c := range cases {
6473 var b bytes.Buffer
6474 printAppRegion(&b, c.res, c.asked)
6475 if b.String() != c.want {
6476 t.Errorf("case %d: %q, want %q", i, b.String(), c.want)
6477 }
6478 }
6479}
6480```
6481
6482- [ ] **Step 2: Run them to verify they fail**
6483
6484Run: `go test . -run 'TestCreateAppSendsRegionAndTimezone|TestPrintAppRegion' -count=1`
6485Expected: FAIL to compile.
6486
6487- [ ] **Step 3: Extend the wire**
6488
6489In `apiCreateAppResult`:
6490
6491```go
6492 // The app region the console STORED (nearest-region spec §9.1), under
6493 // its name. Absent from an older console, which the CLI reads as "no
6494 // app region recorded".
6495 Region string `json:"region"`
6496 RegionName string `json:"region_name"`
6497 RegionSource string `json:"region_source"`
6498 RegionsAvailable int `json:"regions_available"`
6499```
6500
6501Replace `createApp`'s signature and body struct:
6502
6503```go
6504func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string, controlPlane bool, buildProbe, region, timezone string) (apiCreateAppResult, error) {
6505 in := struct {
6506 Name string `json:"name"`
6507 Place string `json:"place,omitempty"`
6508 ControlPlane bool `json:"control_plane,omitempty"`
6509 BuildProbe string `json:"build_probe,omitempty"`
6510 Region string `json:"region,omitempty"`
6511 Timezone string `json:"timezone,omitempty"`
6512 }{app, place, controlPlane, buildProbe, region, timezone}
6513```
6514
6515Leave the rest of the function unchanged. Update every other call site
6516(codex plan-R2 5). The callers are:
6517
6518- the tests: `apimode_test.go:3041`, `:3052`, `:3060`, `:3066`, `:3099`,
6519 `:3106` and `apps_test.go:311`, each gaining `, "", ""`;
6520- `apps.go:497` and `deploy.go:536`, which get the real values in steps 4
6521 and 5.
6522
6523`offerCreateApp` also changes signature in step 5, so update its one test
6524caller, `deploy_wizard_test.go:104`, to pass `""` as the new last argument:
6525
6526```go
6527 app := offerCreateApp(context.Background(), s, apiAcct{Seg: "bdk", Exact: true},
6528 &noAppsError{Account: "tom@keymail.dev"}, "")
6529```
6530
6531Run `grep -n 'createApp(\|offerCreateApp(' *.go` afterwards. Every match
6532must carry the new arity.
6533
6534- [ ] **Step 4: Add `printAppRegion` and wire `apps create`**
6535
6536In `region_copy.go`'s neighbour, a new file `apps_region.go`:
6537
6538```go
6539package main
6540
6541import (
6542 "fmt"
6543 "io"
6544)
6545
6546// printAppRegion is spec §11's one line after "created app …": nothing on
6547// a single-region deployment, C13a when an older console ignored --region,
6548// C13b when a console with the switch off did, else C9 to C12 by source.
6549func printAppRegion(w io.Writer, res apiCreateAppResult, asked string) {
6550 switch {
6551 case res.Region == "" && res.RegionSource == "":
6552 if asked != "" {
6553 fmt.Fprintln(w, regionNotRecordedOldConsole)
6554 }
6555 case asked != "" && res.Region != asked:
6556 fmt.Fprintln(w, regionNotAppliedSwitchOff(res.RegionName))
6557 case res.RegionsAvailable >= 2:
6558 fmt.Fprintln(w, regionCreatedLine(res.RegionName, res.RegionSource))
6559 }
6560}
6561```
6562
6563In `runAppsCreate`, register the flag and pass it on:
6564
6565```go
6566 region := fs.String("region", "", flagRegionAppsCreate)
6567```
6568
6569```go
6570 res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app), place2, *controlPlane, probe2,
6571 strings.TrimSpace(*region), detectTimezone())
6572```
6573
6574After the existing "created app" line at `apps.go:502`:
6575
6576```go
6577 printAppRegion(stdout, res, strings.TrimSpace(*region))
6578```
6579
6580Add the record's U1 text to the usage string at `apps.go:463`.
6581
6582- [ ] **Step 5: Wire `deploy`'s onboarding**
6583
6584In `runDeploy`'s flags:
6585
6586```go
6587 regionFlag := fs.String("region", "", flagRegionDeploy)
6588```
6589
6590Pass it to `offerCreateApp(ctx, sess, acct, na, strings.TrimSpace(*regionFlag))`.
6591Change that function's signature to take `region string`, and its create call
6592and output to:
6593
6594```go
6595 res, err := sess.createApp(ctx, acct, name, "", false, "", region, detectTimezone())
6596 if err != nil {
6597 log.Fatalf("deploy: create app: %v", err)
6598 }
6599 fmt.Fprintf(stdout, "%s %s in account %s\n", colorFor(stdout).ok("created app"), res.App, res.Account)
6600 printAppRegion(stdout, res, region)
6601 return res.App
6602```
6603
6604Add the record's U2 text to `runDeploy`'s usage string.
6605
6606- [ ] **Step 6: Run the root package tests**
6607
6608Run: `go test . -count=1 -timeout 30m`
6609Expected: PASS. The e2e tests shell out through `carlosTestEnv`, so nothing
6610reaches a real console.
6611
6612- [ ] **Step 7: Commit**
6613
6614```bash
6615git add apimode.go apps.go apps_region.go apps_region_test.go deploy.go apps_test.go apimode_test.go deploy_wizard_test.go
6616git commit -m "cli: apps create and deploy onboarding send a timezone, take --region, print the region line (nearest-region §11)
6617
6618Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
6619```
6620
6621---
6622
6623### Task 21: `deploy --region` on an existing app
6624
6625**Files:**
6626- Modify: `apimode.go` (add `appRegions`)
6627- Modify: `deploy.go` (`runDeploy`: `createdNow`, and the preflight before any ship)
6628- Test: `deploy_region_test.go`, `deploy_region_e2e_test.go`
6629
6630**Interfaces:**
6631- Consumes: `deployRegionMismatch`, `deployRegionUnreadable` and
6632 `deployRegionUnknown` (Task 18).
6633- Produces:
6634
6635```go
6636type apiAppDefault struct {
6637 Region, Name, Source, Effective, Reason, InstanceDomain string
6638 Available *bool
6639 RegionKnown, PlacementKnown bool
6640}
6641type apiRegionEntry struct{ Code, Name string }
6642type apiRegionsResult struct {
6643 Regions []apiRegionEntry `json:"regions"`
6644 Default string `json:"default"`
6645 AppDefault *apiAppDefault `json:"app_default"`
6646}
6647func (s *apiSession) appRegions(ctx context.Context, a apiAcct, app string) (apiRegionsResult, error)
6648func (r apiRegionsResult) nameOf(code string) string // a place name, else the code
6649func deployRegionPreflight(res apiRegionsResult, readErr error, app, asked string) error
6650```
6651
6652`regionE2EConsole` is a root-package test helper, reused by Task 22. It
6653counts writes, and separately the instance-create requests (`creates`), so
6654Task 22 can tell a duplicate create from the other writes.
6655
6656- [ ] **Step 1: Write the failing unit test**
6657
6658```go
6659// deploy_region_test.go
6660package main
6661
6662import (
6663 "errors"
6664 "testing"
6665)
6666
6667// codex plan-R1 2: a READ failure stops deploy; only a successful response
6668// without app_default (an older console) skips the check.
6669func TestDeployRegionPreflight(t *testing.T) {
6670 uk := &apiAppDefault{Region: "uk", Name: "P-uk", Effective: "uk", RegionKnown: true, PlacementKnown: true}
6671 fleet := &apiAppDefault{Region: "uk", Name: "P-uk", Effective: "fleet/acme", Reason: "fleet", RegionKnown: true, PlacementKnown: true}
6672 boom := errors.New("500 from console")
6673 cases := []struct {
6674 name string
6675 res apiRegionsResult
6676 err error
6677 asked string
6678 want string
6679 }{
6680 {"no flag", apiRegionsResult{AppDefault: uk}, boom, "", ""},
6681 {"equal", apiRegionsResult{AppDefault: uk}, nil, "uk", ""},
6682 {"different", apiRegionsResult{AppDefault: uk}, nil, "ie", deployRegionMismatch("blog", "P-uk")},
6683 {"older console", apiRegionsResult{}, nil, "ie", ""},
6684 {"read failed", apiRegionsResult{}, boom, "ie", deployRegionUnreadable("blog", boom)},
6685 {"fleet, stored equal", apiRegionsResult{AppDefault: fleet}, nil, "uk", ""},
6686 {"fleet, stored different", apiRegionsResult{AppDefault: fleet}, nil, "ie", deployRegionMismatch("blog", "P-uk")},
6687 // codex plan-R2 3: a 200 whose stored region could not be read.
6688 {"region unknown", apiRegionsResult{AppDefault: &apiAppDefault{RegionKnown: false, PlacementKnown: true}}, nil, "ie", deployRegionUnknown("blog")},
6689 }
6690 for _, c := range cases {
6691 err := deployRegionPreflight(c.res, c.err, "blog", c.asked)
6692 got := ""
6693 if err != nil {
6694 got = err.Error()
6695 }
6696 if got != c.want {
6697 t.Errorf("%s: %q, want %q", c.name, got, c.want)
6698 }
6699 }
6700}
6701```
6702
6703- [ ] **Step 2: Run it to verify it fails**
6704
6705Run: `go test . -run TestDeployRegionPreflight -count=1`
6706Expected: FAIL.
6707
6708- [ ] **Step 3: Implement the read and the preflight**
6709
6710In `apimode.go`:
6711
6712```go
6713// apiAppDefault, apiRegionEntry and apiRegionsResult are the CLI's view of
6714// GET …/regions (nearest-region spec §9.3). AppDefault is nil from a
6715// console that predates it.
6716type apiAppDefault struct {
6717 Region string `json:"region"`
6718 Name string `json:"name"`
6719 Source string `json:"source"`
6720 Effective string `json:"effective"`
6721 Reason string `json:"reason"`
6722 Available *bool `json:"available"`
6723 RegionKnown bool `json:"region_known"`
6724 PlacementKnown bool `json:"placement_known"`
6725 InstanceDomain string `json:"instance_domain"`
6726}
6727
6728type apiRegionEntry struct {
6729 Code string `json:"code"`
6730 Name string `json:"name"`
6731}
6732
6733type apiRegionsResult struct {
6734 Regions []apiRegionEntry `json:"regions"`
6735 Default string `json:"default"`
6736 AppDefault *apiAppDefault `json:"app_default"`
6737}
6738
6739// nameOf is the place name the console gave code, else the code itself.
6740func (r apiRegionsResult) nameOf(code string) string {
6741 for _, e := range r.Regions {
6742 if e.Code == code && e.Name != "" {
6743 return e.Name
6744 }
6745 }
6746 return code
6747}
6748
6749func (s *apiSession) appRegions(ctx context.Context, a apiAcct, app string) (apiRegionsResult, error) {
6750 status, body, err := s.getJSON(ctx, apiPath(a, app, "/regions"))
6751 if err != nil {
6752 return apiRegionsResult{}, err
6753 }
6754 if status != http.StatusOK {
6755 return apiRegionsResult{}, apiErrorFromBody(status, body)
6756 }
6757 var out apiRegionsResult
6758 if err := json.Unmarshal(body, &out); err != nil {
6759 return apiRegionsResult{}, fmt.Errorf("regions: unreadable response: %w", err)
6760 }
6761 return out, nil
6762}
6763```
6764
6765In `deploy.go`:
6766
6767```go
6768// deployRegionPreflight is spec §11: --region on an EXISTING app is
6769// compared with the app's STORED region before anything ships. A failed
6770// read STOPS the deploy (C15b), and so does a successful answer whose
6771// stored region the console could not read (C15c, codex plan-R2 3):
6772// either way the region is unknown, and shipping would go to whatever the
6773// app already is. Only a successful response without app_default — an
6774// older console — skips the check.
6775func deployRegionPreflight(res apiRegionsResult, readErr error, app, asked string) error {
6776 if asked == "" {
6777 return nil
6778 }
6779 if readErr != nil {
6780 return errors.New(deployRegionUnreadable(app, readErr))
6781 }
6782 if res.AppDefault == nil {
6783 return nil
6784 }
6785 if !res.AppDefault.RegionKnown {
6786 return errors.New(deployRegionUnknown(app))
6787 }
6788 if res.AppDefault.Region == asked {
6789 return nil
6790 }
6791 return errors.New(deployRegionMismatch(app, res.AppDefault.Name))
6792}
6793```
6794
6795In `runDeploy`, declare `createdNow := false` just before `if *app == "" {`,
6796and set it in the onboarding case:
6797
6798```go
6799 case errors.As(aerr, &na) && interactiveTerminal():
6800 *app = offerCreateApp(ctx, sess, acct, na, strings.TrimSpace(*regionFlag))
6801 newly["app"] = *app
6802 createdNow = true
6803```
6804
6805Immediately after that block closes, before `readCapabilities` and any ship:
6806
6807```go
6808 if r := strings.TrimSpace(*regionFlag); r != "" && !createdNow {
6809 res, err := sess.appRegions(ctx, acct, *app)
6810 if err := deployRegionPreflight(res, err, *app, r); err != nil {
6811 log.Fatalf("deploy: %v", err)
6812 }
6813 }
6814```
6815
6816- [ ] **Step 4: Run the unit test to verify it passes**
6817
6818Run: `go test . -run TestDeployRegionPreflight -count=1`
6819Expected: PASS.
6820
6821- [ ] **Step 5: Write the command-level test (codex plan-R1 2, 12)**
6822
6823This test runs the built `carlos` binary against an in-process console. It
6824proves that every refusal happens before any write: no POST reaches the
6825console, and no release object lands in the store.
6826
6827```go
6828// deploy_region_e2e_test.go
6829package main
6830
6831import (
6832 "context"
6833 "encoding/json"
6834 "errors"
6835 "net/http"
6836 "net/http/httptest"
6837 "os"
6838 "os/exec"
6839 "path/filepath"
6840 "strings"
6841 "sync/atomic"
6842 "testing"
6843 "time"
6844
6845 "github.com/carlosframework/platform/internal/console"
6846 "github.com/carlosframework/platform/internal/release"
6847)
6848
6849// regionE2E is an in-process console with titogo claimed at appRegion, the
6850// signed-in CLI credentials saved, and a front that can fail or strip the
6851// /regions answer and counts every write reaching the console.
6852type regionE2E struct {
6853 bin string // built before useTempCredentials changes directory
6854 creds string
6855 url string
6856 store release.Store
6857 mode atomic.Value // "", "fail", "legacy", "region-unknown", "placement-unknown"
6858 writes atomic.Int64
6859 // creates counts instance-create requests (POST …/instances), so a
6860 // test can tell a duplicate create from the other writes (Task 22).
6861 creates atomic.Int64
6862 cst *console.Store
6863 // failGet is a store key whose reads fail while the listing succeeds,
6864 // the way one bucket GET can (Task 22, codex plan-R3 1).
6865 failGet atomic.Value
6866}
6867
6868// flakyStore fails every read of one key.
6869type flakyStore struct {
6870 release.Store
6871 key *atomic.Value
6872}
6873
6874func (f flakyStore) Get(ctx context.Context, key string) ([]byte, string, error) {
6875 if k, _ := f.key.Load().(string); k != "" && k == key {
6876 return nil, "", errors.New("read timeout")
6877 }
6878 return f.Store.Get(ctx, key)
6879}
6880
6881func regionE2EConsole(t *testing.T, appRegion string) *regionE2E {
6882 t.Helper()
6883 bin := buildCarlos(t) // first: useTempCredentials chdirs out of the module
6884 creds := useTempCredentials(t)
6885 clearDeploymentEnv(t)
6886 const addr, app = "paul@keymail.dev", "titogo"
6887 tmp := t.TempDir()
6888 e := &regionE2E{bin: bin, creds: creds, store: release.NewDirStore(filepath.Join(tmp, "store"))}
6889 e.mode.Store("")
6890 e.failGet.Store("")
6891 seed, pubB64, err := release.GenerateKey()
6892 if err != nil {
6893 t.Fatal(err)
6894 }
6895 priv, err := release.ParseSigningKey(seed, "e2e")
6896 if err != nil {
6897 t.Fatal(err)
6898 }
6899 pub, err := release.ParsePublicKey(pubB64, "e2e")
6900 if err != nil {
6901 t.Fatal(err)
6902 }
6903 e.cst, err = console.Open(filepath.Join(tmp, "console.db"))
6904 if err != nil {
6905 t.Fatal(err)
6906 }
6907 t.Cleanup(func() { e.cst.Close() })
6908 var srv *console.Server
6909 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6910 if r.Method != http.MethodGet && r.Method != http.MethodHead && !strings.Contains(r.URL.Path, "/auth/") {
6911 e.writes.Add(1)
6912 }
6913 if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/instances") {
6914 e.creates.Add(1)
6915 }
6916 if strings.HasSuffix(r.URL.Path, "/regions") {
6917 switch e.mode.Load().(string) {
6918 case "fail":
6919 http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError)
6920 return
6921 case "legacy", "region-unknown", "placement-unknown":
6922 // A successful 200 whose body an older console, or a
6923 // console that could not read one fact, would send.
6924 rec := httptest.NewRecorder()
6925 srv.ServeHTTP(rec, r)
6926 var m map[string]any
6927 json.Unmarshal(rec.Body.Bytes(), &m)
6928 d, _ := m["app_default"].(map[string]any)
6929 switch e.mode.Load().(string) {
6930 case "legacy":
6931 delete(m, "app_default")
6932 case "region-unknown":
6933 d["region_known"], d["region"], d["effective"] = false, "", ""
6934 case "placement-unknown":
6935 d["placement_known"], d["reason"] = false, "policy-unreadable"
6936 }
6937 w.Header().Set("Content-Type", "application/json")
6938 json.NewEncoder(w).Encode(m)
6939 return
6940 }
6941 }
6942 srv.ServeHTTP(w, r)
6943 }))
6944 t.Cleanup(ts.Close)
6945 e.url = ts.URL
6946 srv, err = console.NewServer(console.Config{
6947 Origin: ts.URL, Keymail: "http://keymail.invalid",
6948 Registry: filepath.Join(tmp, "registry.db"), Releases: flakyStore{e.store, &e.failGet},
6949 ReleasePub: pub, Key: priv, AppScheme: "http", AppsDomain: "oncarlos.test",
6950 HomeRegion: "ie",
6951 }, e.cst)
6952 if err != nil {
6953 t.Fatal(err)
6954 }
6955 if err := e.cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil {
6956 t.Fatal(err)
6957 }
6958 if _, err := e.cst.CreateAppInRegion(console.OpsAccountID, app, addr, appRegion, "chosen", time.Now()); err != nil {
6959 t.Fatal(err)
6960 }
6961 token, expiresAt := e2eDeviceLogin(t, ts.URL, e.cst, addr, "e2e@laptop")
6962 credFile, err := loadCredentials()
6963 if err != nil {
6964 t.Fatal(err)
6965 }
6966 credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt})
6967 if err := credFile.save(); err != nil {
6968 t.Fatal(err)
6969 }
6970 e.writes.Store(0) // the login above is setup, not the command under test
6971 return e
6972}
6973
6974// run runs the built carlos binary as a signed-in user with no project
6975// config in reach.
6976func (e *regionE2E) run(t *testing.T, args ...string) (string, error) {
6977 t.Helper()
6978 cmd := exec.Command(e.bin, args...)
6979 cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+e.creds)
6980 cmd.Dir = t.TempDir()
6981 out, err := cmd.CombinedOutput()
6982 return string(out), err
6983}
6984
6985func (e *regionE2E) releases(t *testing.T) int {
6986 t.Helper()
6987 keys, err := e.store.List(context.Background(), release.AppPrefix(release.OpsAccount, "titogo"))
6988 if err != nil {
6989 t.Fatal(err)
6990 }
6991 n := 0
6992 for _, k := range keys {
6993 if strings.Contains(k, "/releases/") {
6994 n++
6995 }
6996 }
6997 return n
6998}
6999
7000func artifact(t *testing.T) string {
7001 t.Helper()
7002 p := filepath.Join(t.TempDir(), "app")
7003 if err := os.WriteFile(p, []byte("#!/bin/sh\n"), 0o755); err != nil {
7004 t.Fatal(err)
7005 }
7006 return p
7007}
7008
7009func TestDeployRegionRefusalsShipNothing(t *testing.T) {
7010 for _, c := range []struct{ mode, want string }{
7011 {"", deployRegionMismatch("titogo", "")[:20]}, // C15: the stored region is uk
7012 {"fail", "deploy stopped before shipping anything"}, // C15b
7013 {"region-unknown", deployRegionUnknown("titogo")[:30]}, // C15c: a 200 that does not know
7014 } {
7015 // A subtest each: useTempCredentials chdirs, and only a subtest
7016 // restores the directory before the next case builds the binary.
7017 t.Run("mode="+c.mode, func(t *testing.T) {
7018 e := regionE2EConsole(t, "uk")
7019 e.mode.Store(c.mode)
7020 out, err := e.run(t, "deploy", "--app", "titogo", "--region", "ie", "--console", e.url,
7021 "--channel", "canary/e2e", "--version", "abc1234", "--timeout", "1s", artifact(t))
7022 if err == nil || !strings.Contains(out, c.want) {
7023 t.Fatalf("err=%v out=%s, want a refusal containing %q", err, out, c.want)
7024 }
7025 if n := e.writes.Load(); n != 0 {
7026 t.Fatalf("%d write(s) reached the console before the refusal", n)
7027 }
7028 if n := e.releases(t); n != 0 {
7029 t.Fatalf("%d release object(s) were shipped", n)
7030 }
7031 })
7032 }
7033}
7034
7035// An older console (a successful /regions without app_default) skips the
7036// check, and the deploy goes on to ship.
7037func TestDeployRegionSkipsAgainstAnOlderConsole(t *testing.T) {
7038 e := regionE2EConsole(t, "uk")
7039 e.mode.Store("legacy")
7040 out, _ := e.run(t, "deploy", "--app", "titogo", "--region", "ie", "--console", e.url,
7041 "--channel", "canary/e2e", "--version", "abc1234", "--timeout", "1s", artifact(t))
7042 if strings.Contains(out, deployRegionMismatch("titogo", "")[:20]) || strings.Contains(out, "deploy stopped before shipping") {
7043 t.Fatalf("an older console refused: %s", out)
7044 }
7045 if e.writes.Load() == 0 || e.releases(t) == 0 {
7046 t.Fatalf("deploy never shipped: %s", out)
7047 }
7048}
7049```
7050
7051The `want` fragments come from the copy functions themselves, so they follow
7052the approved text. The C15b fragment is a literal substring of its draft; if
7053review changed it, use a substring of the approved text.
7054`useTempCredentials`, `clearDeploymentEnv`, `e2eDeviceLogin`,
7055`loadCredentials`, `credential` and `buildCarlos` are the existing root test
7056helpers used by `TestE2EDeployThroughConsole`
7057(`deploy_e2e_test.go:266-330`). `useTempCredentials` calls `t.Chdir`
7058(`auth_test.go:26`), which is why the binary is built first.
7059
7060- [ ] **Step 6: Run the command-level tests**
7061
7062Run: `go test . -run 'TestDeployRegion' -count=1 -timeout 30m`
7063Expected: PASS.
7064
7065- [ ] **Step 7: Commit**
7066
7067```bash
7068git add apimode.go deploy.go deploy_region_test.go deploy_region_e2e_test.go
7069git commit -m "cli: deploy --region checks the stored region before shipping and stops on a failed read (nearest-region §11)
7070
7071Co-