carlos / platform Public

docs: nearest-region default implementation plan (placement, R8)

25 tasks implementing the approved spec rev 5: box eligibility fields,
internal/geo (points, nearest, tz, AWS region table, optional GeoIP),
console store/config/candidates/guess/claim/picker/door/regions
endpoints/instances tab/drift log, CLI timezone and --region on apps
create, deploy and canary, the region service-gate script, docs, and a
whole-branch gate. Five copy tasks end at copy-review gates; the branch
does not land before all are approved. Phase L stays out (R8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9513d52ca15e726122699333aa9facc04aefc093 parent 4604a14
1 file changed, +5490 −0
  • docs/superpowers/plans/2026-10-02-nearest-region-default.md +5490 −0
diff --git a/docs/superpowers/plans/2026-10-02-nearest-region-default.md b/docs/superpowers/plans/2026-10-02-nearest-region-default.md
new file mode 100644
index 000000000..08aee2e8a
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-02-nearest-region-default.md
@@ -0,0 +1,5490 @@
+# Nearest Region by Default: Implementation Plan
+
+> **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.
+
+**Goal:** Give every new app a region at creation, preselected from a timezone-then-IP guess, and have new instances default to that region, without changing existing apps, older CLIs, or DNS.
+
+**Architecture:**
+
+- **Boxes** publish two more facts in their edges file, `serves_shared_pool`
+ and `accepts_inbound`. These let the console tell which regions can mint an
+ ordinary instance.
+- **A new package, `internal/geo`,** turns timezones and IPs into points and
+ picks the great-circle nearest eligible region.
+- **The console** stores `region` and `region_source` on the `apps` row and
+ resolves them inside `claimApp`, after it knows whether the claim is an
+ adoption. It fills an empty instance region from the app's region, before
+ the existing checks. It adds an `app_default` block to `GET …/regions` and
+ a POST guess endpoint.
+- **The CLI** sends a timezone hint and gains `--region` where the spec says.
+
+All non-home writes sit behind `CONSOLE_APP_REGIONS=on` together with a named
+home.
+
+**Tech Stack:**
+
+- Go 1.26 and SQLite (modernc).
+- An embedded IANA tzdata `zone.tab` and its links.
+- `github.com/oschwald/maxminddb-golang` v1.13.1 for the optional
+ `CONSOLE_GEOIP_DB`.
+- `net/http` handlers and `html/template` console pages.
+
+**Spec:** `docs/superpowers/specs/2026-10-01-nearest-region-default-design.md`
+(rev 5, approved by Paul 2026-10-02; Astra round 5: ready for planning). Read
+it beside this plan. Section numbers below (§N) are the spec's.
+
+## Global Constraints
+
+- **Scope is placement only (R8).** No DNS writes, no `Steering` stamp, no
+ converger change, and no `CARLOS_STEERING_ZONE_ID`. Phase L is a separate
+ spec.
+- **Nothing writes a non-home app region** unless `CONSOLE_APP_REGIONS=on`
+ **and** `CONSOLE_HOME_REGION` is set (§5.6). If the switch is on and the
+ home is unnamed, log once at startup and behave as off.
+- **With the switch off, the door's fill still runs** for rows that already
+ hold a region (§5.6).
+- **`region` is stored in the record vocabulary: `""` is the home** (§5.1).
+ Normalize with `homeRegionIn` on the way in and `homeRegionOut` on the way
+ out.
+- **`region_source` ∈ {`chosen`, `timezone`, `ip`, `default`, `""`}.**
+ Nothing branches on it.
+- **Never stored, logged, audited, put in a URL or echoed in a response:** a
+ timezone, an IP, or a coordinate (§6.5). Hints travel only in POST bodies.
+- **Every non-null guess passes**
+ `instanceRegionAllowed(m.Regions, homeRegionIn(code))`. A home fallback that
+ fails it gives `null` (§6.4).
+- **Automatic choices need positive eligibility**: reachable, and
+ `hibernation_configured`, `serves_shared_pool` and `accepts_inbound` all
+ explicitly `true` on one box. Explicit requests keep today's gate (§6.1).
+- **App-derived defaults never pass on the dormant gate.** An edges read
+ failure is 503 C28 (§7).
+- **Pooled manifests and fleet-placed apps get no instance guess** (§6.1,
+ codex R4-1).
+- **The app-create fallback.** If the app-create resolution cannot read the
+ edges files, it falls back to `""`/`default` (§6.1).
+- **Wire changes are additive.** New request fields are optional, and new
+ response fields are `omitempty` where absence is meaningful
+ (AGENTS.md, "Wire-shape changes are additive").
+- **All user-facing text comes from the copy tasks** (Tasks 6, 7, 18, 24 and
+ the place names in Task 4). Drafts are verbatim from spec §19. **The
+ branch is not landed until Paul has approved every copy task through the
+ copy-review skill and the approved text is applied verbatim.** Code tasks
+ reference the copy identifiers, never literals.
+- **No em dashes in any user-facing string**: a full stop and a new sentence
+ instead.
+- **Gate.** `go build ./...`, `go vet ./...`, `gofmt -l .` (must be empty) and
+ `go test ./... -count=1` are clean before every commit that touches Go
+ (`make test` runs all of it).
+- **Cross-compile gate.** `GOOS=darwin go build .`,
+ `GOOS=windows go build .` and `GOOS=linux GOARCH=arm64 go build ./...` must
+ pass. The CLI code added here uses only portable calls and no build tags.
+- **Test environment.**
+ - Use the default Go cache. Inside the sandbox, use `GOCACHE=$TMPDIR/gocache`
+ (on disk) and `GOFLAGS=-mod=mod`. Never put a Go cache under `/tmp`.
+ - Unix-socket tests need `TMPDIR=/tmp/g` (`mkdir -p /tmp/g`).
+ - Console tests need an on-disk `TMPDIR` (for example `$TMPDIR` inside the
+ sandbox), because an empty `/tmp/.git` breaks Go's VCS stamping.
+- **CLI tests that shell out to the carlos binary** build their env through
+ `carlosTestEnv` and set `cmd.Dir` to a scratch dir (AGENTS.md).
+- **Flags are documented.** `docs/site/cli.md` documents every new flag. No
+ new commands are added, so the docsite heading gate is unaffected.
+- **Commits.** End every commit message with
+ `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Do not push.
+ Run `git config` only with `--worktree`.
+
+## Review Focus
+
+The five inputs most likely to bite a person that no spec-derived test covers.
+Each is pinned by a test in the task named.
+
+1. **Real-machine timezone text.** An `/etc/timezone` with a trailing newline
+ or spaces, `TZ=":"`, `TZ="localtime"` and `TZ="posixrules"` must give a
+ clean name or nothing, never a malformed hint. Pinned in Task 19 and
+ Task 3.
+2. **IPv6 and IPv4-mapped clients.** `::ffff:81.2.69.142` locates exactly as
+ `81.2.69.142`. Private IPv6 (`fc00::/7`, `fe80::/10`) and loopback `::1`
+ never reach the reader. Pinned in Task 5.
+3. **A region that stops being eligible between rendering the new-app page and
+ submitting it.** The POST re-renders with C5 and writes no app row. Pinned
+ in Task 13.
+4. **Garbage hints at the guess endpoint.** `"ip":"not-an-ip"` and a
+ 10 KB `"tz"` are treated as absent: a 200 with whatever the rest yields,
+ never a 400 or 500. Pinned in Task 15.
+5. **A claim that fails after resolution.** A name that is taken or an apex
+ that is held writes no row, and logs no `app region` line. Pinned in
+ Task 12.
+
+---
+
+## File map
+
+| File | Responsibility | Tasks |
+| --- | --- | --- |
+| `internal/provision/reconcile.go` | `ReconcileConfig.SharedInstanceEligibility()` | 1 |
+| `internal/host/edgesfile.go` | publish `serves_shared_pool`, `accepts_inbound` | 1 |
+| `internal/geo/point.go`, `nearest.go` | points, haversine, nearest-with-ties | 2 |
+| `internal/geo/tz.go`, `data/*` | embedded `zone.tab`, links, version; `ZonePoint`, `CountryPoint` | 3 |
+| `scripts/update-tzgeo.sh` | refresh the embedded tz files from IANA | 3 |
+| `internal/geo/awsregions.go` | AWS region → point and place name (copy C23) | 4 |
+| `internal/geo/mmdb.go` | optional GeoIP `Locator` | 5 |
+| `internal/console/regioncopy.go` | console page copy (C1, C2, C3, C5, C7, C8, C21, C24) | 6 |
+| `internal/console/regionmsgs.go` | API messages (C16 to C20, C26 to C29) | 7 |
+| `internal/console/store.go` | columns, rebuild list, `CreateAppInRegion`, `AppRegion`, `NonHomeAppRegions` | 8 |
+| `internal/console/server.go` (Config), `cmd/console/main.go` | `AppRegions`, `GeoIP`, `appRegionsOn` | 9 |
+| `internal/console/regioncandidates.go` | strict edges read, candidates, eligibility, policy | 10 |
+| `internal/console/regionguess.go` | `guessRegion` and its invariant | 11 |
+| `internal/console/regionclaim.go`, `server.go`, `apicli.go` | `regionReq`, explicit validation, resolution in `claimApp`, the API create door | 12 |
+| `internal/console/regionpicker.go`, `templates/app_new.html`, `server.go` | the new-app picker, POST and browser guess route | 13 |
+| `internal/console/apiinstances.go` | the fill, app-derived eligibility, the replica-overlap refusal, response fields | 14 |
+| `internal/console/apiregions.go`, `authcli.go` | `app_default`, names, `POST …/regions/guess` | 15 |
+| `templates/app_instances.html`, `server.go` | the Instances tab lines | 16 |
+| `internal/console/regiondrift.go`, `cmd/console/main.go` | roll-forward discrepancy log | 17 |
+| `region_copy.go` (root) | CLI copy (C9 to C15, C13a, C13b, C22, C25, C30) | 18 |
+| `timezone.go` (root) | timezone detection | 19 |
+| `apimode.go`, `apps.go`, `deploy.go` | create-app wire, `apps create --region`, onboarding line | 20 |
+| `deploy.go`, `apimode.go` | `deploy --region` preflight on an existing app | 21 |
+| `canary.go`, `instances_cli.go` | `canary --region`, instance output | 22 |
+| `docs/site/cli.md`, `README.md`, `scripts/region-service-gate.sh` | docs, release floor, service-gate script | 23, 24 |
+
+Task order is dependency order. Tasks 6, 7, 18 and 24 are copy tasks. Each
+ends at a copy-review gate. Later code tasks may proceed against the draft
+identifiers while review runs, but the branch does not land until every
+gate has been approved and applied.
+
+---
+
+### Task 1: Boxes publish shared-pool and inbound eligibility
+
+**Files:**
+- Modify: `internal/provision/reconcile.go` (beside `poolServed`, `:1339`)
+- Modify: `internal/host/edgesfile.go:155-206` (struct), `:600-611` (ident in `edgesFileLoop`)
+- Test: `internal/provision/reconcile_eligibility_test.go` (create), `internal/host/edgesfile_test.go`
+
+**Interfaces:**
+- Consumes: `provision.ReconcileConfig{Pools []string; Inbound string}`,
+ `provision.InboundNone`, `poolServed`, and `provisionTickConfig(getenv)
+ provision.ReconcileConfig` (`internal/host/agent.go:2030`).
+- Produces:
+ - `func (cfg ReconcileConfig) SharedInstanceEligibility() (sharedPool, inbound bool)`;
+ - `edgesFile.ServesSharedPool bool` (`json:"serves_shared_pool"`, not
+ omitempty);
+ - `edgesFile.AcceptsInbound bool` (`json:"accepts_inbound"`, not
+ omitempty).
+
+- [ ] **Step 1: Write the failing provision test**
+
+```go
+// internal/provision/reconcile_eligibility_test.go
+package provision
+
+import "testing"
+
+// SharedInstanceEligibility must be the reconciler's own predicates, never a
+// second reading of the environment (spec §6.1, codex R3-3).
+func TestSharedInstanceEligibilityIsTheMintPredicate(t *testing.T) {
+ cases := []struct {
+ pools []string
+ inbound string
+ shared, accept bool
+ }{
+ {nil, "", true, true},
+ {[]string{"tito"}, "", false, true},
+ {[]string{"", "tito"}, "", true, true},
+ {nil, InboundNone, true, false},
+ {[]string{"tito"}, InboundNone, false, false},
+ }
+ for _, c := range cases {
+ cfg := ReconcileConfig{Pools: c.pools, Inbound: c.inbound}
+ shared, inbound := cfg.SharedInstanceEligibility()
+ if shared != poolServed(c.pools, "") || inbound != (c.inbound != InboundNone) {
+ t.Fatalf("%+v: eligibility diverges from the mint predicates", c)
+ }
+ if shared != c.shared || inbound != c.accept {
+ t.Fatalf("%+v: got (%v, %v), want (%v, %v)", c, shared, inbound, c.shared, c.accept)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `go test ./internal/provision -run TestSharedInstanceEligibilityIsTheMintPredicate -count=1`
+Expected: FAIL, `cfg.SharedInstanceEligibility undefined`.
+
+- [ ] **Step 3: Implement it beside `poolServed`**
+
+```go
+// SharedInstanceEligibility answers, from this box's own reconcile config,
+// whether it mints an ORDINARY instance record: one in the shared ""
+// pool, needing inbound service. The edges file publishes both answers
+// (serves_shared_pool, accepts_inbound) so the console never offers an
+// automatic choice of a region whose boxes would refuse the record
+// (nearest-region spec §6.1). It is the reconciler's own predicates —
+// poolServed and the CARLOS_INBOUND=none refusal at reconcile.go:925 — so
+// publication and minting cannot disagree.
+func (cfg ReconcileConfig) SharedInstanceEligibility() (sharedPool, inbound bool) {
+ return poolServed(cfg.Pools, ""), cfg.Inbound != InboundNone
+}
+```
+
+- [ ] **Step 4: Run it to verify it passes**
+
+Run: `go test ./internal/provision -run TestSharedInstanceEligibilityIsTheMintPredicate -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Write the failing edges-file tests**
+
+Append to `internal/host/edgesfile_test.go`:
+
+```go
+// The two eligibility fields are NOT omitempty: absent means "a writer
+// that predates the field" (unknown), false means "no" (spec §6.1).
+func TestEdgesFileRawWireBodyEligibilityFields(t *testing.T) {
+ for _, want := range []bool{true, false} {
+ s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
+ seedRoute(t, reg, "app.example.com")
+ var last []byte
+ ident := edgesFile{Box: "quiet-harbour", Region: "eu-west-2", Placement: "uk",
+ ServesSharedPool: want, AcceptsInbound: want}
+ if err := edgesFilePass(context.Background(), s, reg, ident, "203.0.113.10", &last); err != nil {
+ t.Fatal(err)
+ }
+ got, _, err := s.Get(context.Background(), "control/edges/quiet-harbour.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var raw map[string]any
+ if err := json.Unmarshal(got, &raw); err != nil {
+ t.Fatal(err)
+ }
+ for _, k := range []string{"serves_shared_pool", "accepts_inbound"} {
+ v, present := raw[k]
+ if !present || v != want {
+ t.Fatalf("%s = %v (present %v), want %v always published", k, v, present, want)
+ }
+ }
+ }
+}
+
+// What the loop publishes is the reconcile config's own answer, for every
+// spelling an operator might write. CARLOS_POOLS=",tito" parses to
+// ["tito"] (agent.go's parser drops empty elements), so it does NOT serve
+// the shared pool and must publish false (codex R3-3).
+func TestEdgesFileEligibilityFollowsTheReconcileConfig(t *testing.T) {
+ cases := []struct {
+ pools, inbound string
+ shared, accept bool
+ }{
+ {"", "", true, true},
+ {"tito", "", false, true},
+ {",tito", "", false, true},
+ {"", "none", true, false},
+ {"tito", "NONE", false, false},
+ }
+ for _, c := range cases {
+ getenv := func(k string) string {
+ switch k {
+ case "CARLOS_POOLS":
+ return c.pools
+ case "CARLOS_INBOUND":
+ return c.inbound
+ }
+ return ""
+ }
+ shared, inbound := edgesFileEligibility(getenv)
+ cfg := provisionTickConfig(getenv)
+ wantShared, wantInbound := cfg.SharedInstanceEligibility()
+ if shared != wantShared || inbound != wantInbound {
+ t.Fatalf("%+v: published (%v, %v) but the reconciler says (%v, %v)", c, shared, inbound, wantShared, wantInbound)
+ }
+ if shared != c.shared || inbound != c.accept {
+ t.Fatalf("%+v: got (%v, %v), want (%v, %v)", c, shared, inbound, c.shared, c.accept)
+ }
+ }
+}
+```
+
+- [ ] **Step 6: Run them to verify they fail**
+
+Run: `TMPDIR=/tmp/g go test ./internal/host -run 'TestEdgesFile(RawWireBodyEligibilityFields|EligibilityFollowsTheReconcileConfig)' -count=1`
+Expected: FAIL, unknown fields `ServesSharedPool` and `AcceptsInbound`, and
+`edgesFileEligibility` undefined.
+
+- [ ] **Step 7: Implement the fields, the helper and the loop wiring**
+
+In `edgesFile`, after `HibernationConfigured`:
+
+```go
+ // ServesSharedPool and AcceptsInbound are this box's own answers to
+ // "would my reconcile pass mint an ORDINARY instance record" — one in
+ // the shared "" pool, needing inbound service (nearest-region spec
+ // §6.1). Deliberately NOT omitempty, HibernationConfigured's precedent:
+ // absent is a writer that predates the field (unknown), false is a box
+ // that said no. The console needs both positively true before it makes
+ // an AUTOMATIC choice of this box's region; explicit requests ignore
+ // them. Computed from the same ReconcileConfig the reconcile pass uses
+ // (edgesFileEligibility), never a second reading of the environment.
+ ServesSharedPool bool `json:"serves_shared_pool"`
+ AcceptsInbound bool `json:"accepts_inbound"`
+```
+
+Below `edgesFileRegion`:
+
+```go
+// edgesFileEligibility derives the two published eligibility answers from
+// the reconcile config this box actually runs with — provisionTickConfig,
+// the same parse the provision tick uses — so a spelling the reconciler
+// reads one way can never be published another.
+func edgesFileEligibility(getenv func(string) string) (sharedPool, inbound bool) {
+ return provisionTickConfig(getenv).SharedInstanceEligibility()
+}
+```
+
+In `edgesFileLoop`, build the ident with the two new fields:
+
+```go
+ sharedPool, inbound := edgesFileEligibility(os.Getenv)
+ ident := edgesFile{
+ Box: box,
+ Region: region,
+ Placement: strings.TrimSpace(os.Getenv("CARLOS_REGION")),
+ DataBucket: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_BUCKET")),
+ DataBucketRegion: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_REGION")),
+ // Same env, same trim, one line apart, deliberately: this boolean
+ // must never disagree with the bucket beside it, and the way to
+ // guarantee that is to derive both from the one read rather than to
+ // ask two questions that could drift.
+ HibernationConfigured: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_BUCKET")) != "",
+ ServesSharedPool: sharedPool,
+ AcceptsInbound: inbound,
+ }
+```
+
+- [ ] **Step 8: Run the host package to verify it passes**
+
+Run: `mkdir -p /tmp/g && TMPDIR=/tmp/g go test ./internal/host ./internal/provision -count=1`
+Expected: PASS. In particular, `TestEdgesFileRawWireBodyMatchesSchema` still
+passes. If it pins the full key set, add the two keys to its expected set,
+because the schema really did grow.
+
+- [ ] **Step 9: Cross-compile, then commit**
+
+Run: `GOOS=darwin go build . && GOOS=windows go build . && GOOS=linux GOARCH=arm64 go build ./...`
+Expected: all succeed, because `internal/host` is linked into the CLI.
+
+```bash
+git add internal/provision/reconcile.go internal/provision/reconcile_eligibility_test.go internal/host/edgesfile.go internal/host/edgesfile_test.go
+git commit -m "edges file: publish serves_shared_pool and accepts_inbound (nearest-region §6.1)
+
+Computed from the reconcile config the box mints with, so an automatic
+region choice can never land on a box that refuses ordinary instances.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: `internal/geo` points, distance and the nearest pick
+
+**Files:**
+- Create: `internal/geo/point.go`, `internal/geo/nearest.go`
+- Test: `internal/geo/nearest_test.go`
+
+**Interfaces:**
+- Consumes: `LookupAWSRegion` from Task 4. To keep this task testable on its
+ own, `Nearest` takes a `pointOf func(awsRegion string) (Point, bool)`
+ parameter, and Task 4 adds the exported wrapper.
+- Produces:
+ - `type Point struct{ Lat, Lon float64 }`;
+ - `func DistanceKm(a, b Point) float64`;
+ - `type Candidate struct{ Code string; Home bool; AWSRegion string }`;
+ - `func NearestWith(p Point, cands []Candidate, pointOf func(string) (Point, bool)) (code string, ok bool)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/geo/nearest_test.go
+package geo
+
+import (
+ "math"
+ "testing"
+)
+
+var testPoints = map[string]Point{
+ "eu-west-1": {53.35, -6.26},
+ "eu-west-2": {51.51, -0.13},
+ "ap-southeast-2": {-33.87, 151.21},
+}
+
+func testPointOf(r string) (Point, bool) { p, ok := testPoints[r]; return p, ok }
+
+func TestDistanceKmDublinLondon(t *testing.T) {
+ d := DistanceKm(testPoints["eu-west-1"], testPoints["eu-west-2"])
+ if math.Abs(d-464) > 5 {
+ t.Fatalf("Dublin to London = %.1f km, want about 464", d)
+ }
+}
+
+func TestNearest(t *testing.T) {
+ london := Point{51.5, -0.12}
+ sydney := Point{-33.9, 151.2}
+ home := Candidate{Code: "", Home: true, AWSRegion: "eu-west-1"}
+ uk := Candidate{Code: "uk", AWSRegion: "eu-west-2"}
+ au := Candidate{Code: "au", AWSRegion: "ap-southeast-2"}
+
+ cases := []struct {
+ name string
+ p Point
+ cands []Candidate
+ want string
+ wantOK bool
+ }{
+ {"single", london, []Candidate{home}, "", true},
+ {"clear winner", london, []Candidate{home, uk, au}, "uk", true},
+ {"far side", sydney, []Candidate{home, uk, au}, "au", true},
+ {"home with no point gives no answer", london,
+ []Candidate{{Code: "", Home: true, AWSRegion: ""}, uk}, "", false},
+ {"non-home with no point is left out", london,
+ []Candidate{home, {Code: "zz", AWSRegion: "xx-nowhere-1"}}, "", true},
+ {"empty", london, nil, "", false},
+ {"tie goes to the home", testPoints["eu-west-2"],
+ []Candidate{{Code: "uk2", AWSRegion: "eu-west-2"}, {Code: "", Home: true, AWSRegion: "eu-west-2"}}, "", true},
+ {"non-home tie goes to the smaller code", testPoints["eu-west-2"],
+ []Candidate{{Code: "uk2", AWSRegion: "eu-west-2"}, {Code: "uk", AWSRegion: "eu-west-2"}}, "uk", true},
+ }
+ for _, c := range cases {
+ got, ok := NearestWith(c.p, c.cands, testPointOf)
+ if got != c.want || ok != c.wantOK {
+ t.Errorf("%s: got (%q, %v), want (%q, %v)", c.name, got, ok, c.want, c.wantOK)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `go test ./internal/geo -count=1`
+Expected: FAIL, because the package does not exist.
+
+- [ ] **Step 3: Implement `point.go`**
+
+```go
+// Package geo turns a timezone or an IP into a point on the globe and picks
+// the nearest of a set of AWS regions (nearest-region spec §6). It holds
+// points only for the length of one call: nothing here stores, logs or
+// returns a caller's coordinates.
+package geo
+
+import "math"
+
+// Point is a latitude and longitude in degrees.
+type Point struct{ Lat, Lon float64 }
+
+// earthRadiusKm is the IUGG mean Earth radius.
+const earthRadiusKm = 6371.0088
+
+// DistanceKm is the great-circle (haversine) distance between a and b.
+func DistanceKm(a, b Point) float64 {
+ rad := func(d float64) float64 { return d * math.Pi / 180 }
+ dLat := rad(b.Lat - a.Lat)
+ dLon := rad(b.Lon - a.Lon)
+ h := math.Sin(dLat/2)*math.Sin(dLat/2) +
+ math.Cos(rad(a.Lat))*math.Cos(rad(b.Lat))*math.Sin(dLon/2)*math.Sin(dLon/2)
+ return 2 * earthRadiusKm * math.Asin(math.Sqrt(h))
+}
+```
+
+- [ ] **Step 4: Implement `nearest.go`**
+
+```go
+package geo
+
+import "math"
+
+// Candidate is one region a guess may pick. Code is in the record
+// vocabulary ("" is the deployment's home); the caller has already
+// filtered to eligible, policy-allowed candidates.
+type Candidate struct {
+ Code string
+ Home bool
+ AWSRegion string
+}
+
+// NearestWith returns the candidate nearest p, measured to each
+// candidate's AWS region through pointOf.
+//
+// - A candidate whose region has no point is left out.
+// - If the HOME is among cands and has no point, ok is false: picking
+// among the rest could choose a far region over an unmeasured near
+// home, and the home is where today's behaviour lives (spec §6.3).
+// - Distances are compared in whole kilometres. A tie goes to the home,
+// then to the lexically smallest code, so every console answers alike.
+func NearestWith(p Point, cands []Candidate, pointOf func(string) (Point, bool)) (code string, ok bool) {
+ best := -1
+ bestKm := math.MaxInt
+ for i, c := range cands {
+ cp, has := pointOf(c.AWSRegion)
+ if !has {
+ if c.Home {
+ return "", false
+ }
+ continue
+ }
+ km := int(math.Round(DistanceKm(p, cp)))
+ switch {
+ case best < 0, km < bestKm:
+ case km == bestKm && better(c, cands[best]):
+ default:
+ continue
+ }
+ best, bestKm = i, km
+ }
+ if best < 0 {
+ return "", false
+ }
+ return cands[best].Code, true
+}
+
+// better breaks an exact tie: the home first, then the smaller code.
+func better(a, b Candidate) bool {
+ if a.Home != b.Home {
+ return a.Home
+ }
+ return a.Code < b.Code
+}
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `go test ./internal/geo -count=1`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/geo/point.go internal/geo/nearest.go internal/geo/nearest_test.go
+git commit -m "geo: haversine and the nearest-region pick with deterministic ties (nearest-region §6.3)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 3: `internal/geo` timezones and country points
+
+**Files:**
+- Create: `internal/geo/data/zone.tab`, `internal/geo/data/links.txt`, `internal/geo/data/VERSION`
+- Create: `internal/geo/tz.go`, `scripts/update-tzgeo.sh`
+- Test: `internal/geo/tz_test.go`
+
+**Interfaces:**
+- Consumes: `Point` from Task 2.
+- Produces:
+ - `func ZonePoint(name string) (Point, bool)`;
+ - `func CountryPoint(iso2 string) (Point, bool)`;
+ - `func TZDataVersion() string`.
+
+- [ ] **Step 1: Write the refresh script**
+
+```bash
+#!/usr/bin/env bash
+# scripts/update-tzgeo.sh <version>, e.g. 2026b
+#
+# Refreshes internal/geo/data from one IANA tzdata release
+# (nearest-region spec §6.2). zone.tab carries the coordinates; the Link
+# lines resolve the old names browsers still report (Asia/Calcutta). Run it
+# as a release-checklist step beside the Go toolchain bump; a stale table
+# only misses zone names newer than it, and those fall through to IP.
+set -euo pipefail
+v="${1:?usage: update-tzgeo.sh <tzdata version, e.g. 2026b>}"
+here="$(cd "$(dirname "$0")/.." && pwd)"
+out="$here/internal/geo/data"
+work="$(mktemp -d "${TMPDIR:-/tmp}/tzgeo.XXXXXX")"
+trap 'rm -rf "$work"' EXIT
+base="https://data.iana.org/time-zones/releases"
+curl -fsSL -o "$work/tzdata.tar.gz" "$base/tzdata$v.tar.gz"
+curl -fsSL -o "$work/tzdata.tar.gz.asc" "$base/tzdata$v.tar.gz.asc"
+if command -v gpg >/dev/null; then
+ gpg --verify "$work/tzdata.tar.gz.asc" "$work/tzdata.tar.gz"
+else
+ echo "gpg not found: signature NOT checked" >&2
+ exit 1
+fi
+tar -xzf "$work/tzdata.tar.gz" -C "$work"
+mkdir -p "$out"
+cp "$work/zone.tab" "$out/zone.tab"
+awk '$1 == "Link" { print $2, $3 }' \
+ "$work"/africa "$work"/antarctica "$work"/asia "$work"/australasia \
+ "$work"/backward "$work"/etcetera "$work"/europe "$work"/northamerica \
+ "$work"/southamerica | sort -u > "$out/links.txt"
+printf '%s\n' "$v" > "$out/VERSION"
+echo "internal/geo/data refreshed to tzdata $v"
+```
+
+`chmod +x scripts/update-tzgeo.sh`.
+
+- [ ] **Step 2: Generate the data**
+
+Run: `scripts/update-tzgeo.sh 2026b`. This needs network access to
+`data.iana.org` and `gpg` with the IANA key; outside the sandbox, use
+`dangerouslyDisableSandbox` for this one command.
+
+Expected: three files in `internal/geo/data/`. `links.txt` has lines like
+`Asia/Kolkata Asia/Calcutta`, and `VERSION` contains `2026b`.
+
+- [ ] **Step 3: Write the failing tests**
+
+```go
+// internal/geo/tz_test.go
+package geo
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestZonePoint(t *testing.T) {
+ mapped := []string{
+ "Europe/London", "Europe/Dublin", "Europe/Amsterdam",
+ "Asia/Kolkata", "Asia/Calcutta",
+ "America/Argentina/Buenos_Aires", "europe/london",
+ }
+ for _, z := range mapped {
+ if _, ok := ZonePoint(z); !ok {
+ t.Errorf("ZonePoint(%q) unmapped, want a point", z)
+ }
+ }
+ a, _ := ZonePoint("Asia/Calcutta")
+ b, _ := ZonePoint("Asia/Kolkata")
+ if a != b {
+ t.Errorf("Asia/Calcutta %v != Asia/Kolkata %v; the link must resolve to one point", a, b)
+ }
+ unmapped := []string{
+ "", "UTC", "Etc/UTC", "Etc/GMT+5", "GMT", "UCT", "Universal", "Zulu",
+ "Factory", "Local", "localtime", "posixrules", "../etc/passwd",
+ "Europe/London/Extra/Deep", strings.Repeat("A", 65), "Nowhere/Atlantis",
+ }
+ for _, z := range unmapped {
+ if p, ok := ZonePoint(z); ok {
+ t.Errorf("ZonePoint(%q) = %v, want unmappable", z, p)
+ }
+ }
+ l, _ := ZonePoint("Europe/London")
+ if l.Lat < 51 || l.Lat > 52 || l.Lon > 0.5 || l.Lon < -0.5 {
+ t.Errorf("Europe/London = %v, want about 51.5, -0.12", l)
+ }
+}
+
+// A link cycle must terminate (at most 4 hops) rather than hang.
+func TestZonePointLinkLoopTerminates(t *testing.T) {
+ links := map[string]string{"a/x": "a/y", "a/y": "a/x"}
+ if _, ok := resolveLinks("a/x", links, map[string]Point{}); ok {
+ t.Fatal("a link loop resolved to a point")
+ }
+}
+
+// Every country with more than one zone.tab row needs an override naming
+// its principal zone: a tzdata refresh that gives a country a second row
+// fails here instead of silently averaging a continent (spec §6.2).
+func TestCountryOverridesCoverEveryMultiZoneCountry(t *testing.T) {
+ for cc, n := range zoneRowsPerCountry() {
+ if n < 2 {
+ continue
+ }
+ zone, ok := countryOverride[cc]
+ if !ok {
+ t.Errorf("country %s has %d zone.tab rows and no override", cc, n)
+ continue
+ }
+ if _, ok := ZonePoint(zone); !ok {
+ t.Errorf("override for %s names %q, which is not in zone.tab", cc, zone)
+ }
+ }
+ for cc := range countryOverride {
+ if zoneRowsPerCountry()[cc] < 2 {
+ t.Errorf("override for %s is unused: it has fewer than two rows", cc)
+ }
+ }
+}
+
+func TestCountryPoint(t *testing.T) {
+ for _, cc := range []string{"GB", "IE", "US", "AU", "IN", "gb"} {
+ if _, ok := CountryPoint(cc); !ok {
+ t.Errorf("CountryPoint(%q) unmapped", cc)
+ }
+ }
+ us, _ := CountryPoint("US")
+ ny, _ := ZonePoint("America/New_York")
+ if us != ny {
+ t.Errorf("US = %v, want its principal zone New York %v", us, ny)
+ }
+ if _, ok := CountryPoint("ZZ"); ok {
+ t.Error("CountryPoint(ZZ) mapped")
+ }
+}
+
+func TestTZDataVersion(t *testing.T) {
+ if v := TZDataVersion(); v == "" || strings.ContainsAny(v, " \n") {
+ t.Fatalf("TZDataVersion = %q", v)
+ }
+}
+```
+
+- [ ] **Step 4: Run them to verify they fail**
+
+Run: `go test ./internal/geo -run 'TestZonePoint|TestCountry|TestTZDataVersion' -count=1`
+Expected: FAIL, undefined `ZonePoint`.
+
+- [ ] **Step 5: Implement `tz.go`**
+
+```go
+package geo
+
+import (
+ _ "embed"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+//go:embed data/zone.tab
+var zoneTab string
+
+//go:embed data/links.txt
+var linksTxt string
+
+//go:embed data/VERSION
+var tzVersion string
+
+// TZDataVersion is the IANA release the embedded tables came from.
+func TZDataVersion() string { return strings.TrimSpace(tzVersion) }
+
+var (
+ tzOnce sync.Once
+ zonePoints map[string]Point // lowercased zone name -> point
+ zoneLinks map[string]string
+ countryOne map[string]Point // countries with exactly one row
+ rowsPerCC map[string]int
+)
+
+// zoneShape is spec §6.2 step 1: at most 64 bytes, one to three
+// "/"-separated segments of [A-Za-z0-9_+-].
+var zoneShape = regexp.MustCompile(`^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+){0,2}$`)
+
+// notAPlace names an offset or a machine, not a place (R3).
+var notAPlace = map[string]bool{
+ "": true, "local": true, "localtime": true, "posixrules": true,
+ "utc": true, "uct": true, "gmt": true, "universal": true, "zulu": true,
+ "factory": true,
+}
+
+func loadTZ() {
+ zonePoints = map[string]Point{}
+ countryOne = map[string]Point{}
+ rowsPerCC = map[string]int{}
+ for _, line := range strings.Split(zoneTab, "\n") {
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ f := strings.Split(line, "\t")
+ if len(f) < 3 {
+ continue
+ }
+ p, ok := parseISO6709(f[1])
+ if !ok {
+ continue
+ }
+ zonePoints[strings.ToLower(f[2])] = p
+ cc := strings.ToUpper(f[0])
+ rowsPerCC[cc]++
+ countryOne[cc] = p
+ }
+ zoneLinks = map[string]string{}
+ for _, line := range strings.Split(linksTxt, "\n") {
+ f := strings.Fields(line)
+ if len(f) == 2 {
+ zoneLinks[strings.ToLower(f[1])] = strings.ToLower(f[0])
+ }
+ }
+}
+
+// ZonePoint maps an IANA zone name to its principal location, following
+// tzdata links (at most 4 hops). It refuses anything that is not a place.
+func ZonePoint(name string) (Point, bool) {
+ tzOnce.Do(loadTZ)
+ name = strings.TrimSpace(name)
+ if len(name) > 64 || !zoneShape.MatchString(name) {
+ return Point{}, false
+ }
+ low := strings.ToLower(name)
+ if notAPlace[low] || strings.HasPrefix(low, "etc/") {
+ return Point{}, false
+ }
+ return resolveLinks(low, zoneLinks, zonePoints)
+}
+
+func resolveLinks(name string, links map[string]string, points map[string]Point) (Point, bool) {
+ for hop := 0; hop <= 4; hop++ {
+ if p, ok := points[name]; ok {
+ return p, true
+ }
+ next, ok := links[name]
+ if !ok {
+ return Point{}, false
+ }
+ name = next
+ }
+ return Point{}, false
+}
+
+// countryOverride names the principal zone of every country with more
+// than one zone.tab row (tzdata 2026b). TestCountryOverridesCoverEvery
+// MultiZoneCountry fails when a refresh adds a multi-row country.
+var countryOverride = map[string]string{
+ "AQ": "Antarctica/McMurdo", "AR": "America/Argentina/Buenos_Aires",
+ "AU": "Australia/Sydney", "BR": "America/Sao_Paulo", "CA": "America/Toronto",
+ "CD": "Africa/Kinshasa", "CL": "America/Santiago", "CN": "Asia/Shanghai",
+ "CY": "Asia/Nicosia", "DE": "Europe/Berlin", "EC": "America/Guayaquil",
+ "ES": "Europe/Madrid", "FM": "Pacific/Pohnpei", "GL": "America/Nuuk",
+ "ID": "Asia/Jakarta", "KI": "Pacific/Tarawa", "KZ": "Asia/Almaty",
+ "MH": "Pacific/Majuro", "MN": "Asia/Ulaanbaatar", "MX": "America/Mexico_City",
+ "MY": "Asia/Kuala_Lumpur", "NZ": "Pacific/Auckland", "PF": "Pacific/Tahiti",
+ "PG": "Pacific/Port_Moresby", "PS": "Asia/Gaza", "PT": "Europe/Lisbon",
+ "RU": "Europe/Moscow", "UA": "Europe/Kyiv", "UM": "Pacific/Wake",
+ "US": "America/New_York", "UZ": "Asia/Tashkent",
+}
+
+// CountryPoint maps an ISO 3166-1 alpha-2 code to a point: its only
+// zone.tab row, or its override's zone.
+func CountryPoint(iso2 string) (Point, bool) {
+ tzOnce.Do(loadTZ)
+ cc := strings.ToUpper(strings.TrimSpace(iso2))
+ if zone, ok := countryOverride[cc]; ok {
+ return ZonePoint(zone)
+ }
+ if rowsPerCC[cc] == 1 {
+ return countryOne[cc], true
+ }
+ return Point{}, false
+}
+
+func zoneRowsPerCountry() map[string]int { tzOnce.Do(loadTZ); return rowsPerCC }
+
+// parseISO6709 reads zone.tab's ±DDMM±DDDMM or ±DDMMSS±DDDMMSS.
+func parseISO6709(s string) (Point, bool) {
+ i := strings.IndexAny(s[1:], "+-") + 1
+ if i <= 0 {
+ return Point{}, false
+ }
+ lat, ok1 := dms(s[:i], 2)
+ lon, ok2 := dms(s[i:], 3)
+ return Point{lat, lon}, ok1 && ok2
+}
+
+func dms(s string, degDigits int) (float64, bool) {
+ sign := 1.0
+ if s[0] == '-' {
+ sign = -1
+ }
+ d := s[1:]
+ if len(d) != degDigits+2 && len(d) != degDigits+4 {
+ return 0, false
+ }
+ deg, err1 := strconv.Atoi(d[:degDigits])
+ min, err2 := strconv.Atoi(d[degDigits : degDigits+2])
+ sec := 0
+ var err3 error
+ if len(d) == degDigits+4 {
+ sec, err3 = strconv.Atoi(d[degDigits+2:])
+ }
+ if err1 != nil || err2 != nil || err3 != nil {
+ return 0, false
+ }
+ return sign * (float64(deg) + float64(min)/60 + float64(sec)/3600), true
+}
+```
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+Run: `go test ./internal/geo -count=1`
+Expected: PASS. If `TestCountryOverridesCoverEveryMultiZoneCountry` names a
+country, the downloaded tzdata differs from 2026b. Add or remove that
+override using the principal zone, which is the country's capital or largest
+city.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/geo/tz.go internal/geo/tz_test.go internal/geo/data scripts/update-tzgeo.sh
+git commit -m "geo: embedded IANA zone.tab and links, zone and country points (nearest-region §6.2)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 4: AWS region table and place names (copy C23, copy-review gate)
+
+**Files:**
+- Create: `internal/geo/awsregions.go`
+- Test: `internal/geo/awsregions_test.go`
+
+**Interfaces:**
+- Consumes: `Point`, `NearestWith` from Task 2.
+- Produces:
+ - `type AWSRegion struct{ Point; Place string }`;
+ - `func LookupAWSRegion(code string) (AWSRegion, bool)`;
+ - `func Nearest(p Point, cands []Candidate) (string, bool)`, which is
+ `NearestWith` over this table;
+ - `func PlaceName(awsRegion, fallback string) string`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/geo/awsregions_test.go
+package geo
+
+import (
+ "regexp"
+ "strings"
+ "testing"
+)
+
+func TestAWSRegionTable(t *testing.T) {
+ shape := regexp.MustCompile(`^[a-z]{2}(-[a-z]+)+-\d$`)
+ for code, r := range awsRegions {
+ if !shape.MatchString(code) {
+ t.Errorf("%s is not an AWS region code", code)
+ }
+ if r.Lat < -90 || r.Lat > 90 || r.Lon < -180 || r.Lon > 180 {
+ t.Errorf("%s: %v out of range", code, r.Point)
+ }
+ if r.Place == "" || strings.Contains(r.Place, "—") {
+ t.Errorf("%s: place %q empty or contains an em dash", code, r.Place)
+ }
+ }
+ if len(awsRegions) != 34 {
+ t.Errorf("%d regions, want the 34 of spec C23", len(awsRegions))
+ }
+ if r, _ := LookupAWSRegion("eu-west-2"); r.Place != "UK · London" {
+ t.Errorf("eu-west-2 place = %q", r.Place)
+ }
+ if PlaceName("xx-nowhere-1", "zz") != "zz" || PlaceName("", "ie") != "ie" {
+ t.Error("PlaceName must fall back to the given code")
+ }
+}
+
+func TestNearestUsesTheTable(t *testing.T) {
+ london, _ := ZonePoint("Europe/London")
+ got, ok := Nearest(london, []Candidate{
+ {Code: "", Home: true, AWSRegion: "eu-west-1"},
+ {Code: "uk", AWSRegion: "eu-west-2"},
+ })
+ if !ok || got != "uk" {
+ t.Fatalf("Nearest from London = (%q, %v), want uk", got, ok)
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `go test ./internal/geo -run 'TestAWSRegionTable|TestNearestUsesTheTable' -count=1`
+Expected: FAIL, undefined `awsRegions`.
+
+- [ ] **Step 3: Implement the table, with place names verbatim from spec C23**
+
+```go
+package geo
+
+// AWSRegion is one AWS commercial region's metro-area point and the place
+// name every surface shows for it (spec §14, copy C23: pending Paul's copy
+// review — do not change a Place without it).
+//
+// Source: AWS's official region list
+// (https://docs.aws.amazon.com/global-infrastructure/latest/regions/aws-regions.html)
+// as of 2026-10-01. Coordinates are each region's metro area, checked by
+// hand; AWS publishes no facility locations, and tens of kilometres never
+// change a choice between regions.
+type AWSRegion struct {
+ Point
+ Place string
+}
+
+var awsRegions = map[string]AWSRegion{
+ "us-east-1": {Point{39.04, -77.49}, "US East · N. Virginia"},
+ "us-east-2": {Point{39.96, -83.00}, "US East · Ohio"},
+ "us-west-1": {Point{37.77, -122.42}, "US West · N. California"},
+ "us-west-2": {Point{45.84, -119.70}, "US West · Oregon"},
+ "ca-central-1": {Point{45.50, -73.57}, "Canada · Montréal"},
+ "ca-west-1": {Point{51.05, -114.07}, "Canada · Calgary"},
+ "mx-central-1": {Point{20.59, -100.39}, "Mexico · Querétaro"},
+ "sa-east-1": {Point{-23.55, -46.63}, "Brazil · São Paulo"},
+ "eu-west-1": {Point{53.35, -6.26}, "Ireland · Dublin"},
+ "eu-west-2": {Point{51.51, -0.13}, "UK · London"},
+ "eu-west-3": {Point{48.86, 2.35}, "France · Paris"},
+ "eu-central-1": {Point{50.11, 8.68}, "Germany · Frankfurt"},
+ "eu-central-2": {Point{47.37, 8.54}, "Switzerland · Zurich"},
+ "eu-south-1": {Point{45.46, 9.19}, "Italy · Milan"},
+ "eu-south-2": {Point{41.65, -0.89}, "Spain · Aragón"},
+ "eu-north-1": {Point{59.33, 18.07}, "Sweden · Stockholm"},
+ "il-central-1": {Point{32.09, 34.78}, "Israel · Tel Aviv"},
+ "me-south-1": {Point{26.23, 50.59}, "Bahrain"},
+ "me-central-1": {Point{25.20, 55.27}, "UAE"},
+ "af-south-1": {Point{-33.92, 18.42}, "South Africa · Cape Town"},
+ "ap-south-1": {Point{19.08, 72.88}, "India · Mumbai"},
+ "ap-south-2": {Point{17.39, 78.49}, "India · Hyderabad"},
+ "ap-east-1": {Point{22.32, 114.17}, "Hong Kong"},
+ "ap-east-2": {Point{25.03, 121.57}, "Taiwan · Taipei"},
+ "ap-southeast-1": {Point{1.35, 103.82}, "Singapore"},
+ "ap-southeast-2": {Point{-33.87, 151.21}, "Australia · Sydney"},
+ "ap-southeast-3": {Point{-6.21, 106.85}, "Indonesia · Jakarta"},
+ "ap-southeast-4": {Point{-37.81, 144.96}, "Australia · Melbourne"},
+ "ap-southeast-5": {Point{3.14, 101.69}, "Malaysia"},
+ "ap-southeast-6": {Point{-36.85, 174.76}, "New Zealand · Auckland"},
+ "ap-southeast-7": {Point{13.76, 100.50}, "Thailand · Bangkok"},
+ "ap-northeast-1": {Point{35.68, 139.69}, "Japan · Tokyo"},
+ "ap-northeast-2": {Point{37.57, 126.98}, "South Korea · Seoul"},
+ "ap-northeast-3": {Point{34.69, 135.50}, "Japan · Osaka"},
+}
+
+// LookupAWSRegion finds a region in the table.
+func LookupAWSRegion(code string) (AWSRegion, bool) {
+ r, ok := awsRegions[code]
+ return r, ok
+}
+
+func tablePoint(code string) (Point, bool) {
+ r, ok := awsRegions[code]
+ return r.Point, ok
+}
+
+// Nearest is NearestWith over the shipped table.
+func Nearest(p Point, cands []Candidate) (string, bool) {
+ return NearestWith(p, cands, tablePoint)
+}
+
+// PlaceName is the table's place for awsRegion, or fallback (the bare
+// placement code) for a region the table does not know.
+func PlaceName(awsRegion, fallback string) string {
+ if r, ok := awsRegions[awsRegion]; ok {
+ return r.Place
+ }
+ return fallback
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test ./internal/geo -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Check the coordinates by hand**
+
+For each row, confirm that the coordinates fall within the named metro area
+(an atlas or any map is enough). Confirm that the 34 codes match AWS's
+current list. Correct any row that does not, and re-run step 4.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/geo/awsregions.go internal/geo/awsregions_test.go
+git commit -m "geo: AWS region table with place names (draft copy C23, pending review)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+- [ ] **Step 7: Copy-review gate (C23)**
+
+Invoke the `copy-review` skill with the 34 `Place` strings and the context
+"region picker option labels and every surface that names a region". Wait
+for Paul's edits. Apply them verbatim to `awsregions.go`, re-run step 4
+(adjust the `eu-west-2` assertion if its text changed), and commit as
+`geo: place names as approved in copy review`. **The branch does not land
+before this gate closes.**
+
+---
+
+### Task 5: Optional GeoIP locator
+
+**Files:**
+- Create: `internal/geo/mmdb.go`
+- Create: `internal/geo/testdata/GeoIP2-City-Test.mmdb`, `internal/geo/testdata/GeoIP2-Country-Test.mmdb`, `internal/geo/testdata/README`
+- Modify: `go.mod`, `go.sum`
+- Test: `internal/geo/mmdb_test.go`
+
+**Interfaces:**
+- Consumes: `Point`, `CountryPoint` from Task 3.
+- Produces:
+ - `type Locator interface{ Locate(ip net.IP) (Point, bool) }`;
+ - `func OpenMMDB(path string) (*MMDB, error)`;
+ - `func (m *MMDB) Locate(ip net.IP) (Point, bool)`;
+ - `func (m *MMDB) Close() error`;
+ - `func Locatable(ip net.IP) bool`.
+
+- [ ] **Step 1: Add the dependency and the fixtures (network)**
+
+Run, with network (outside the sandbox if needed):
+
+```bash
+go get github.com/oschwald/maxminddb-golang@v1.13.1
+mkdir -p internal/geo/testdata
+base=https://raw.githubusercontent.com/maxmind/MaxMind-DB/main/test-data
+curl -fsSL -o internal/geo/testdata/GeoIP2-City-Test.mmdb "$base/GeoIP2-City-Test.mmdb"
+curl -fsSL -o internal/geo/testdata/GeoIP2-Country-Test.mmdb "$base/GeoIP2-Country-Test.mmdb"
+git -C . ls-remote https://github.com/maxmind/MaxMind-DB HEAD
+```
+
+Write `internal/geo/testdata/README`:
+
+```
+GeoIP2-City-Test.mmdb and GeoIP2-Country-Test.mmdb are MaxMind's published
+test databases from github.com/maxmind/MaxMind-DB (test-data/), dual
+licensed MIT or Apache-2.0. Pinned at commit <the HEAD sha printed above>.
+They contain test records only, not real geolocation data.
+```
+
+Replace `<the HEAD sha printed above>` with the actual sha before committing.
+
+- [ ] **Step 2: Write the failing tests**
+
+```go
+// internal/geo/mmdb_test.go
+package geo
+
+import (
+ "net"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestOpenMMDBRefusesNonDatabases(t *testing.T) {
+ dir := t.TempDir()
+ missing := filepath.Join(dir, "missing.mmdb")
+ junk := filepath.Join(dir, "junk.mmdb")
+ os.WriteFile(junk, []byte("not a maxmind database"), 0o600)
+ city, _ := os.ReadFile("testdata/GeoIP2-City-Test.mmdb")
+ truncated := filepath.Join(dir, "truncated.mmdb")
+ os.WriteFile(truncated, city[:len(city)/3], 0o600)
+ for _, p := range []string{missing, junk, truncated} {
+ if m, err := OpenMMDB(p); err == nil {
+ m.Close()
+ t.Errorf("OpenMMDB(%s) succeeded, want an error", filepath.Base(p))
+ }
+ }
+}
+
+func TestMMDBCityRecordGivesItsLocation(t *testing.T) {
+ m, err := OpenMMDB("testdata/GeoIP2-City-Test.mmdb")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer m.Close()
+ p, ok := m.Locate(net.ParseIP("81.2.69.142"))
+ if !ok || p.Lat < 51 || p.Lat > 52 {
+ t.Fatalf("81.2.69.142 = (%v, %v), want a London point", p, ok)
+ }
+ // Review Focus 2: an IPv4-mapped IPv6 address is the same client.
+ mapped, ok2 := m.Locate(net.ParseIP("::ffff:81.2.69.142"))
+ if !ok2 || mapped != p {
+ t.Fatalf("::ffff:81.2.69.142 = (%v, %v), want %v", mapped, ok2, p)
+ }
+}
+
+func TestMMDBCountryOnlyRecordGivesTheCountryPoint(t *testing.T) {
+ m, err := OpenMMDB("testdata/GeoIP2-Country-Test.mmdb")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer m.Close()
+ p, ok := m.Locate(net.ParseIP("81.2.69.142"))
+ gb, _ := CountryPoint("GB")
+ if !ok || p != gb {
+ t.Fatalf("country-only 81.2.69.142 = (%v, %v), want GB's point %v", p, ok, gb)
+ }
+}
+
+// Review Focus 2: addresses that locate nothing never reach the reader.
+func TestLocatableRefusesNonPublicAddresses(t *testing.T) {
+ for _, s := range []string{
+ "127.0.0.1", "::1", "10.1.2.3", "192.168.0.1", "172.16.0.1",
+ "100.64.0.1", "169.254.1.1", "fe80::1", "fd00::1", "0.0.0.0", "::",
+ } {
+ if Locatable(net.ParseIP(s)) {
+ t.Errorf("Locatable(%s) = true", s)
+ }
+ }
+ if !Locatable(net.ParseIP("81.2.69.142")) || !Locatable(net.ParseIP("2001:4860::1")) {
+ t.Error("a public address must be locatable")
+ }
+ var m *MMDB
+ if _, ok := m.Locate(net.ParseIP("81.2.69.142")); ok {
+ t.Error("a nil *MMDB located an address")
+ }
+}
+```
+
+If 81.2.69.142 is absent from `GeoIP2-Country-Test.mmdb`, pick any address
+that the fixture's source JSON
+(`source-data/GeoIP2-Country-Test.json` in the same repository and commit)
+maps to a country, and assert that country's `CountryPoint`.
+
+- [ ] **Step 3: Run them to verify they fail**
+
+Run: `go test ./internal/geo -run 'MMDB|Locatable' -count=1`
+Expected: FAIL, undefined `OpenMMDB`.
+
+- [ ] **Step 4: Implement `mmdb.go`**
+
+```go
+package geo
+
+import (
+ "log"
+ "net"
+ "sync"
+ "time"
+
+ "github.com/oschwald/maxminddb-golang"
+)
+
+// Locator turns a client address into a point. A nil Locator, or a nil
+// *MMDB, locates nothing: the IP step is optional (spec §6.2).
+type Locator interface {
+ Locate(ip net.IP) (Point, bool)
+}
+
+// MMDB is an operator-supplied MaxMind-format database
+// (CONSOLE_GEOIP_DB): GeoLite2/GeoIP2 City or Country, or DB-IP City or
+// Country Lite. Opened once, memory-mapped.
+type MMDB struct{ r *maxminddb.Reader }
+
+// OpenMMDB opens and checks the file. An error means "IP step off"; the
+// console logs it once and starts anyway.
+func OpenMMDB(path string) (*MMDB, error) {
+ r, err := maxminddb.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ if err := r.Verify(); err != nil {
+ r.Close()
+ return nil, err
+ }
+ return &MMDB{r: r}, nil
+}
+
+// Close releases the mapping.
+func (m *MMDB) Close() error {
+ if m == nil {
+ return nil
+ }
+ return m.r.Close()
+}
+
+type mmdbRecord struct {
+ Location struct {
+ Latitude *float64 `maxminddb:"latitude"`
+ Longitude *float64 `maxminddb:"longitude"`
+ } `maxminddb:"location"`
+ Country struct {
+ ISOCode string `maxminddb:"iso_code"`
+ } `maxminddb:"country"`
+ RegisteredCountry struct {
+ ISOCode string `maxminddb:"iso_code"`
+ } `maxminddb:"registered_country"`
+}
+
+var (
+ lookupErrMu sync.Mutex
+ lookupErrLast time.Time
+)
+
+// Locate returns the record's location (City databases), else its
+// country's point, else its registered country's. Nothing about the
+// address or the answer is logged.
+func (m *MMDB) Locate(ip net.IP) (Point, bool) {
+ if m == nil || !Locatable(ip) {
+ return Point{}, false
+ }
+ if v4 := ip.To4(); v4 != nil {
+ ip = v4
+ }
+ var rec mmdbRecord
+ if err := m.r.Lookup(ip, &rec); err != nil {
+ lookupErrMu.Lock()
+ if time.Since(lookupErrLast) > time.Minute {
+ lookupErrLast = time.Now()
+ log.Printf("geo: GeoIP lookup failed (treated as a miss): %v", err)
+ }
+ lookupErrMu.Unlock()
+ return Point{}, false
+ }
+ if rec.Location.Latitude != nil && rec.Location.Longitude != nil {
+ return Point{*rec.Location.Latitude, *rec.Location.Longitude}, true
+ }
+ if p, ok := CountryPoint(rec.Country.ISOCode); ok {
+ return p, true
+ }
+ return CountryPoint(rec.RegisteredCountry.ISOCode)
+}
+
+var cgnat = &net.IPNet{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)}
+
+// Locatable is false for addresses that say nothing about where a person
+// is: loopback, private, link-local, CGNAT and unspecified.
+func Locatable(ip net.IP) bool {
+ if ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
+ ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || cgnat.Contains(ip) {
+ return false
+ }
+ return true
+}
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `go test ./internal/geo -count=1 && go mod tidy && git diff --stat go.mod go.sum`
+Expected: PASS. `go.mod` gains `github.com/oschwald/maxminddb-golang v1.13.1`
+and nothing else beyond its `golang.org/x/sys` requirement.
+
+- [ ] **Step 6: Cross-compile, then commit**
+
+Run: `GOOS=darwin go build ./... && GOOS=windows go build ./... && GOOS=linux GOARCH=arm64 go build ./...`
+Expected: all succeed.
+
+```bash
+git add go.mod go.sum internal/geo/mmdb.go internal/geo/mmdb_test.go internal/geo/testdata
+git commit -m "geo: optional MaxMind-format GeoIP locator (nearest-region §6.2)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 6: Console page copy (C1, C2, C3, C5, C7, C8, C21, C24; copy-review gate)
+
+**Files:**
+- Create: `internal/console/regioncopy.go`
+- Test: `internal/console/regioncopy_test.go`
+
+**Interfaces:**
+- Produces: `var regionCopy regionCopyStrings` with fields `PickerLabel`,
+ `PickerHelp`, `AutoOption`, `Unavailable`, `StartsIn`, `CantStart`,
+ `AuditCreated` and `Attribution`, plus
+ `func (p page) RegionCopy() regionCopyStrings`. Format verbs are `%s`, in
+ the order the comment states.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+// internal/console/regioncopy_test.go
+package console
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+// Every string is a draft from spec §19 until copy review approves it,
+// and none may carry an em dash.
+func TestRegionCopyHasNoEmDashesAndRendersItsVerbs(t *testing.T) {
+ v := reflect.ValueOf(regionCopy)
+ for i := 0; i < v.NumField(); i++ {
+ s := v.Field(i).String()
+ if s == "" || strings.Contains(s, "—") {
+ t.Errorf("%s = %q: empty or has an em dash", v.Type().Field(i).Name, s)
+ }
+ }
+ for _, s := range []string{
+ fmt.Sprintf(regionCopy.AutoOption, "UK · London"),
+ fmt.Sprintf(regionCopy.StartsIn, "UK · London"),
+ fmt.Sprintf(regionCopy.CantStart, "UK · London"),
+ fmt.Sprintf(regionCopy.AuditCreated, "blog", "UK · London"),
+ } {
+ if strings.Contains(s, "%!") {
+ t.Errorf("bad verb: %q", s)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `TMPDIR=$TMPDIR go test ./internal/console -run TestRegionCopy -count=1`
+Expected: FAIL, undefined `regionCopy`.
+
+- [ ] **Step 3: Write the drafts, verbatim from spec §19**
+
+```go
+package console
+
+// regionCopy is every console-page string the nearest-region feature
+// shows (spec §19). DRAFTS pending Paul's copy review: change none of them
+// without it. Verbs are %s in the order each comment names.
+type regionCopyStrings struct {
+ PickerLabel string // C1
+ PickerHelp string // C2
+ AutoOption string // C3: place
+ Unavailable string // C5
+ StartsIn string // C7: place
+ CantStart string // C8: place
+ AuditCreated string // C21: app name, place
+ Attribution string // C24 (linked to https://db-ip.com)
+}
+
+var regionCopy = regionCopyStrings{
+ PickerLabel: "Region",
+ PickerHelp: "Where this app's instances run. We picked the region nearest to you. Choose another if most of your users are somewhere else.",
+ AutoOption: "%s (nearest to you)",
+ Unavailable: "That region can't take new apps right now. Choose another.",
+ StartsIn: "New instances start in %s, this app's region.",
+ CantStart: "New instances can't start in %s, this app's region, right now. Pass a region to place one elsewhere.",
+ AuditCreated: "created app %s in %s",
+ Attribution: "IP location by DB-IP.",
+}
+
+func (p page) RegionCopy() regionCopyStrings { return regionCopy }
+```
+
+- [ ] **Step 4: Run it to verify it passes**
+
+Run: `go test ./internal/console -run TestRegionCopy -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/regioncopy.go internal/console/regioncopy_test.go
+git commit -m "console: region page copy (drafts C1-C8, C21, C24, pending review)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+- [ ] **Step 6: Copy-review gate**
+
+Invoke `copy-review` with the eight strings, each with its spec id and where
+it appears. C24 also needs DB-IP's required attribution wording confirmed
+against <https://db-ip.com/db/lite.php>. Apply Paul's edits verbatim, re-run
+step 4, and commit as `console: region page copy as approved`. **Do not land
+the branch before this closes.**
+
+---
+
+### Task 7: Console API messages (C16 to C20, C26 to C29; copy-review gate)
+
+**Files:**
+- Create: `internal/console/regionmsgs.go`
+- Test: `internal/console/regionmsgs_test.go`
+
+**Interfaces:**
+- Produces:
+
+```go
+func msgAppRegionUnavailable(code, reason string, can []string) string // C16
+func msgAppRegionNotAllowed(code string, allowed []string) string // C17
+func msgRegionUnknown(code string, choices []string) string // C18
+func msgRegionNotArmed(code string, choices []string) string // C19
+const msgAppRegionUnreadable = "…" // C20
+func msgReplicaOverlap(code string) string // C26
+const msgPolicyUnreadable = "…" // C27
+func msgAppRegionUnconfirmed(code string) string // C28
+func msgPlacementDiscrepancy(host, app, code string) string // C29
+func reasonText(reason string) string // "absent" | "not-hosting" | "not-eligible"
+```
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+// internal/console/regionmsgs_test.go
+package console
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRegionMessagesAreDraftCopyWithoutEmDashes(t *testing.T) {
+ all := []string{
+ msgAppRegionUnavailable("uk", "absent", []string{"ie", "au"}),
+ msgAppRegionNotAllowed("uk", []string{"ie"}),
+ msgRegionUnknown("zz", []string{"ie", "uk"}),
+ msgRegionNotArmed("uk", []string{"ie"}),
+ msgAppRegionUnreadable,
+ msgReplicaOverlap("uk"),
+ msgPolicyUnreadable,
+ msgAppRegionUnconfirmed("uk"),
+ msgPlacementDiscrepancy("a.go.tito.io", "blog", "uk"),
+ }
+ for _, s := range all {
+ if s == "" || strings.Contains(s, "—") || strings.Contains(s, "%!") {
+ t.Errorf("bad message %q", s)
+ }
+ }
+ if !strings.Contains(all[0], "no box runs there now") || !strings.Contains(all[0], "ie, au") {
+ t.Errorf("C16 = %q, want the readable reason and the list", all[0])
+ }
+ for r, want := range map[string]string{
+ "absent": "no box runs there now",
+ "not-hosting": "boxes there can't start instances",
+ "not-eligible": "boxes there don't take ordinary instances",
+ } {
+ if reasonText(r) != want {
+ t.Errorf("reasonText(%s) = %q", r, reasonText(r))
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `go test ./internal/console -run TestRegionMessages -count=1`
+Expected: FAIL, undefined.
+
+- [ ] **Step 3: Write the drafts, verbatim from spec §19**
+
+```go
+package console
+
+import (
+ "fmt"
+ "strings"
+)
+
+// The nearest-region feature's API and operator messages (spec §19).
+// DRAFTS pending Paul's copy review: change none of them without it.
+
+func reasonText(reason string) string {
+ switch reason {
+ case "absent":
+ return "no box runs there now"
+ case "not-hosting":
+ return "boxes there can't start instances"
+ case "not-eligible":
+ return "boxes there don't take ordinary instances"
+ }
+ return reason
+}
+
+// C16
+func msgAppRegionUnavailable(code, reason string, can []string) string {
+ 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.",
+ code, reasonText(reason), strings.Join(can, ", "))
+}
+
+// C17
+func msgAppRegionNotAllowed(code string, allowed []string) string {
+ return fmt.Sprintf("this app's region %s is not one its instances may use. Allowed: %s. Pass a region to choose one.",
+ code, strings.Join(allowed, ", "))
+}
+
+// C18
+func msgRegionUnknown(code string, choices []string) string {
+ return fmt.Sprintf("region %s is not one this platform runs in. Choose from: %s.", code, strings.Join(choices, ", "))
+}
+
+// C19
+func msgRegionNotArmed(code string, choices []string) string {
+ return fmt.Sprintf("region %s can't take new apps right now. Choose from: %s.", code, strings.Join(choices, ", "))
+}
+
+// C20
+const msgAppRegionUnreadable = "could not read this app's region. Try again."
+
+// C26
+func msgReplicaOverlap(code string) string {
+ 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)
+}
+
+// C27
+const msgPolicyUnreadable = "could not read this app's instance policy, so there is no region suggestion. Try again."
+
+// C28
+func msgAppRegionUnconfirmed(code string) string {
+ return fmt.Sprintf("could not confirm that this app's region %s can take new instances. Try again, or pass a region.", code)
+}
+
+// C29: an operator log line.
+func msgPlacementDiscrepancy(host, app, code string) string {
+ 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)
+}
+```
+
+- [ ] **Step 4: Run it to verify it passes**
+
+Run: `go test ./internal/console -run TestRegionMessages -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/regionmsgs.go internal/console/regionmsgs_test.go
+git commit -m "console: region API messages (drafts C16-C20, C26-C29, pending review)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+- [ ] **Step 6: Copy-review gate**
+
+Invoke `copy-review` with the nine messages and the three reason phrases.
+Apply Paul's edits verbatim, update the test's expected phrases if they
+changed, re-run step 4, and commit as `console: region API messages as
+approved`. **Do not land the branch before this closes.**
+
+---
+
+### Task 8: Store columns, rebuild list and accessors
+
+**Files:**
+- Modify: `internal/console/store.go` (ALTER list ending `:386`, `migrateAppsKey` `:487-505`, `CreateAppIn` `:1349`)
+- Test: `internal/console/store_region_test.go` (create)
+
+**Interfaces:**
+- Produces:
+
+```go
+func (s *Store) CreateAppInRegion(accountID int64, app, createdBy, region, source string, now time.Time) (created bool, err error)
+func (s *Store) AppRegion(accountID int64, app string) (region, source string, err error)
+type nonHomeAppRegion struct{ Sqid, App, Region string; CreatedAt time.Time }
+func (s *Store) NonHomeAppRegions() ([]nonHomeAppRegion, error)
+```
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/console/store_region_test.go
+package console
+
+import (
+ "database/sql"
+ "path/filepath"
+ "testing"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+func TestAppRegionRoundTripAndDefaults(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ if ok, err := s.CreateAppInRegion(OpsAccountID, "blog", "p@k.dev", "uk", "timezone", now); err != nil || !ok {
+ t.Fatalf("CreateAppInRegion = %v %v", ok, err)
+ }
+ if r, src, err := s.AppRegion(OpsAccountID, "blog"); err != nil || r != "uk" || src != "timezone" {
+ t.Fatalf("AppRegion = %q %q %v", r, src, err)
+ }
+ if ok, _ := s.CreateAppIn(OpsAccountID, "old", "p@k.dev", now); !ok {
+ t.Fatal("CreateAppIn failed")
+ }
+ if r, src, _ := s.AppRegion(OpsAccountID, "old"); r != "" || src != "" {
+ t.Fatalf("CreateAppIn row = %q %q, want the home and no source", r, src)
+ }
+ if r, src, err := s.AppRegion(OpsAccountID, "missing"); err != nil || r != "" || src != "" {
+ t.Fatalf("missing row = %q %q %v, want empty and no error", r, src, err)
+ }
+ // A lost claim race writes nothing (Review Focus 5's store half).
+ if ok, _ := s.CreateAppInRegion(OpsAccountID, "blog", "x@k.dev", "au", "ip", now); ok {
+ t.Fatal("a second claim of the same name succeeded")
+ }
+ if r, _, _ := s.AppRegion(OpsAccountID, "blog"); r != "uk" {
+ t.Fatalf("a lost claim changed the region to %q", r)
+ }
+}
+
+func TestNonHomeAppRegions(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ s.CreateAppInRegion(OpsAccountID, "blog", "p@k.dev", "uk", "chosen", now)
+ s.CreateAppInRegion(OpsAccountID, "home", "p@k.dev", "", "default", now)
+ rows, err := s.NonHomeAppRegions()
+ if err != nil || len(rows) != 1 || rows[0].App != "blog" || rows[0].Region != "uk" || rows[0].Sqid != "bab" {
+ t.Fatalf("NonHomeAppRegions = %+v %v", rows, err)
+ }
+}
+
+// The columns survive every database shape the console may open: one from
+// before them, and one from before migrateAppsKey (spec §5.2). And
+// migrate runs twice without error.
+func TestRegionColumnsSurviveTheAppsKeyRebuild(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "console.db")
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The pre-accounts shape: PRIMARY KEY(app), no later columns.
+ if _, err := db.Exec(`CREATE TABLE apps (app TEXT PRIMARY KEY, created_by TEXT NOT NULL, created_at INTEGER NOT NULL);
+INSERT INTO apps VALUES ('legacy', 'p@k.dev', 1);`); err != nil {
+ t.Fatal(err)
+ }
+ db.Close()
+ for i := 0; i < 2; i++ {
+ s, err := Open(path)
+ if err != nil {
+ t.Fatalf("open %d: %v", i, err)
+ }
+ r, src, err := s.AppRegion(OpsAccountID, "legacy")
+ if err != nil || r != "" || src != "" {
+ t.Fatalf("open %d: legacy row = %q %q %v", i, r, src, err)
+ }
+ if ok, err := s.CreateAppInRegion(OpsAccountID, "new"+string(rune('a'+i)), "p@k.dev", "uk", "chosen", time.Now()); err != nil || !ok {
+ t.Fatalf("open %d: CreateAppInRegion = %v %v", i, ok, err)
+ }
+ s.Close()
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `go test ./internal/console -run 'TestAppRegion|TestNonHomeAppRegions|TestRegionColumns' -count=1`
+Expected: FAIL, undefined `CreateAppInRegion`.
+
+- [ ] **Step 3: Add the columns to the ALTER list**
+
+Directly after the `build_probe` ALTER:
+
+```go
+ // App region (nearest-region spec §5.1): the placement code new
+ // instances default to, in the RECORD vocabulary — "" is the
+ // deployment's home, which is also what every existing row reads, so
+ // the column default IS the backfill (§5.4). region_source is a label
+ // for people and logs: chosen | timezone | ip | default | "" (the row
+ // predates the feature). Nothing branches on it. No timezone, IP or
+ // coordinate is ever stored.
+ `ALTER TABLE apps ADD COLUMN region TEXT NOT NULL DEFAULT '';`,
+ `ALTER TABLE apps ADD COLUMN region_source TEXT NOT NULL DEFAULT '';`,
+```
+
+- [ ] **Step 4: Carry them through the rebuild**
+
+In `migrateAppsKey`, replace the DDL and copy statements:
+
+```go
+ `CREATE TABLE apps_new (
+ app TEXT NOT NULL,
+ account_id INTEGER NOT NULL DEFAULT 1,
+ created_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ deleted_at INTEGER NOT NULL DEFAULT 0,
+ placement TEXT NOT NULL DEFAULT '',
+ control_plane INTEGER NOT NULL DEFAULT 0,
+ build_probe TEXT NOT NULL DEFAULT '',
+ region TEXT NOT NULL DEFAULT '',
+ region_source TEXT NOT NULL DEFAULT '',
+ PRIMARY KEY (account_id, app)
+);`,
+ // The ALTERs above run before migrateAppsKey in migrate(), so
+ // deleted_at, placement, control_plane, build_probe, region and
+ // region_source already exist on the old table by the time this
+ // rebuild copies them — a row already trashed, already placed,
+ // already the control plane, already carrying a build probe or an
+ // app region, when the rebuild runs must not have that state
+ // silently dropped (nearest-region spec §5.2).
+ `INSERT INTO apps_new (app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, region, region_source)
+ SELECT app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, region, region_source FROM apps;`,
+```
+
+- [ ] **Step 5: Add the accessors**
+
+Replace `CreateAppIn`'s body and add the new methods beside it:
+
+```go
+func (s *Store) CreateAppIn(accountID int64, app, createdBy string, now time.Time) (created bool, err error) {
+ return s.CreateAppInRegion(accountID, app, createdBy, "", "", now)
+}
+
+// CreateAppInRegion claims an app name with its region in the SAME
+// insert: an app region has no repair verb, so a separate best-effort
+// write that failed would leave the app at the home for good, silently
+// (nearest-region spec §5.3). region is in the record vocabulary.
+func (s *Store) CreateAppInRegion(accountID int64, app, createdBy, region, source string, now time.Time) (created bool, err error) {
+ res, err := s.db.Exec(`INSERT INTO apps (app, account_id, created_by, created_at, region, region_source)
+VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(account_id, app) DO NOTHING`,
+ app, accountID, createdBy, now.Unix(), region, source)
+ if err != nil {
+ return false, err
+ }
+ n, err := res.RowsAffected()
+ return n > 0, err
+}
+
+// AppRegion reads an app's region and its source label. A missing row
+// reads as the home with no source, the same "absence is the default"
+// answer AppPlacement gives.
+func (s *Store) AppRegion(accountID int64, app string) (region, source string, err error) {
+ err = s.db.QueryRow(`SELECT region, region_source FROM apps WHERE account_id = ? AND app = ?`, accountID, app).
+ Scan(&region, &source)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", "", nil
+ }
+ return region, source, err
+}
+
+// nonHomeAppRegion is one live app holding a region other than the home,
+// for the roll-forward discrepancy log (spec §5.7).
+type nonHomeAppRegion struct {
+ Sqid, App, Region string
+ CreatedAt time.Time
+}
+
+func (s *Store) NonHomeAppRegions() ([]nonHomeAppRegion, error) {
+ rows, err := s.db.Query(`SELECT a.sqid, p.app, p.region, p.created_at FROM apps p
+JOIN accounts a ON a.id = p.account_id
+WHERE p.region != '' AND p.deleted_at = 0 ORDER BY a.sqid, p.app`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []nonHomeAppRegion
+ for rows.Next() {
+ var r nonHomeAppRegion
+ var created int64
+ if err := rows.Scan(&r.Sqid, &r.App, &r.Region, &created); err != nil {
+ return nil, err
+ }
+ r.CreatedAt = time.Unix(created, 0)
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+```
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+Run: `go test ./internal/console -run 'TestAppRegion|TestNonHomeAppRegions|TestRegionColumns|TestOwners|TestCreateApp' -count=1`
+Expected: PASS. Then run the whole package:
+`go test ./internal/console -count=1`. Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/console/store.go internal/console/store_region_test.go
+git commit -m "console store: app region columns, carried through the apps-key rebuild (nearest-region §5)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 9: Console config, the switch and the named-home rule
+
+**Files:**
+- Modify: `internal/console/server.go` (`Config`, near `HomeRegion` `:477-484`; `NewServer`)
+- Modify: `cmd/console/main.go:278` (beside `HomeRegion`)
+- Test: `internal/console/regionconfig_test.go` (create), `cmd/console/main_test.go` (append)
+
+**Interfaces:**
+- Consumes: `geo.Locator`, `geo.OpenMMDB` (Task 5).
+- Produces:
+ - `Config.AppRegions bool`;
+ - `Config.GeoIP geo.Locator`;
+ - `func (s *Server) appRegionsOn() bool`;
+ - `func openGeoIP(path string) geo.Locator` in `cmd/console`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/console/regionconfig_test.go
+package console
+
+import "testing"
+
+// Non-home app regions are written only with the switch on AND a named
+// home (spec §5.6, codex 3).
+func TestAppRegionsOnNeedsANamedHome(t *testing.T) {
+ fx := newConsoleFixture(t)
+ for _, c := range []struct {
+ on bool
+ home string
+ want bool
+ }{{false, "ie", false}, {true, "", false}, {true, "ie", true}} {
+ fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion = c.on, c.home
+ if got := fx.srv.appRegionsOn(); got != c.want {
+ t.Errorf("AppRegions=%v HomeRegion=%q: appRegionsOn = %v, want %v", c.on, c.home, got, c.want)
+ }
+ }
+}
+```
+
+Append to `cmd/console/main_test.go`:
+
+```go
+func TestOpenGeoIPIsOptionalAndNeverFatal(t *testing.T) {
+ if openGeoIP("") != nil {
+ t.Fatal("unset CONSOLE_GEOIP_DB must mean no locator")
+ }
+ if openGeoIP(filepath.Join(t.TempDir(), "missing.mmdb")) != nil {
+ t.Fatal("an unopenable file must mean no locator, not a crash")
+ }
+}
+
+func TestAppRegionsSwitchParses(t *testing.T) {
+ for v, want := range map[string]bool{"on": true, "ON": true, " on ": true, "": false, "off": false, "true": false} {
+ if got := appRegionsSwitch(v); got != want {
+ t.Errorf("appRegionsSwitch(%q) = %v, want %v", v, got, want)
+ }
+ }
+}
+```
+
+Add `"path/filepath"` to that file's imports if it is not already there.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `go test ./internal/console -run TestAppRegionsOn -count=1; go test ./cmd/console -run 'OpenGeoIP|AppRegionsSwitch' -count=1`
+Expected: FAIL, undefined.
+
+- [ ] **Step 3: Add the Config fields and the predicate**
+
+In `Config`, after `HomeRegion`:
+
+```go
+ // AppRegions is CONSOLE_APP_REGIONS=on (nearest-region spec §5.6): the
+ // switch that lets new apps be given a non-home region. Off by default.
+ // Off never stops the create door's fill for rows that already hold a
+ // region — it stops new promises, never broken old ones.
+ AppRegions bool
+ // GeoIP is the optional CONSOLE_GEOIP_DB locator; nil means the IP step
+ // of the region guess is off (spec §6.2).
+ GeoIP geo.Locator
+```
+
+Import `"github.com/carlosframework/platform/internal/geo"` in `server.go`.
+Add the predicate in a new file `internal/console/regionconfig.go`:
+
+```go
+package console
+
+// appRegionsOn is spec §5.6's two-part rule: the switch AND a named home.
+// Without a named home, "" is the only way to say "home" and also means
+// "no preference", so nobody could choose the home over a non-home app
+// default — the switch is treated as off.
+func (s *Server) appRegionsOn() bool {
+ return s.cfg.AppRegions && s.cfg.HomeRegion != ""
+}
+```
+
+In `NewServer`, after the config is stored on the server, log once:
+
+```go
+ if cfg.AppRegions && cfg.HomeRegion == "" {
+ log.Printf("console: CONSOLE_APP_REGIONS=on but CONSOLE_HOME_REGION is unset — app regions stay off until the home is named (nearest-region spec §5.6)")
+ }
+```
+
+- [ ] **Step 4: Wire the environment in `cmd/console/main.go`**
+
+Beside `HomeRegion:` in the `console.Config` literal:
+
+```go
+ AppRegions: appRegionsSwitch(os.Getenv("CONSOLE_APP_REGIONS")),
+ GeoIP: openGeoIP(strings.TrimSpace(os.Getenv("CONSOLE_GEOIP_DB"))),
+```
+
+Add these helpers near `envOr`:
+
+```go
+// appRegionsSwitch reads CONSOLE_APP_REGIONS: exactly "on" (any case,
+// trimmed) turns app regions on; anything else is off.
+func appRegionsSwitch(v string) bool { return strings.EqualFold(strings.TrimSpace(v), "on") }
+
+// openGeoIP opens the optional CONSOLE_GEOIP_DB. Unset, unopenable or not
+// an mmdb means the IP step is off — logged once, never fatal: a missing
+// geo file must never cost the console (nearest-region spec §6.2).
+func openGeoIP(path string) geo.Locator {
+ if path == "" {
+ return nil
+ }
+ m, err := geo.OpenMMDB(path)
+ if err != nil {
+ log.Printf("console: CONSOLE_GEOIP_DB %s is unusable, so the IP step of region guesses is off: %v", path, err)
+ return nil
+ }
+ log.Printf("console: GeoIP database loaded from %s", path)
+ return m
+}
+```
+
+`openGeoIP` must return an untyped `nil` on both failure paths, never a nil
+`*geo.MMDB` inside the interface. The test asserts `== nil`.
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `go test ./internal/console -run TestAppRegionsOn -count=1 && go test ./cmd/console -count=1`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/console/server.go internal/console/regionconfig.go internal/console/regionconfig_test.go cmd/console/main.go cmd/console/main_test.go
+git commit -m "console: CONSOLE_APP_REGIONS switch, named-home rule, optional CONSOLE_GEOIP_DB (nearest-region §5.6, §6.2)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 10: Strict edges read, candidates, eligibility and policy
+
+**Files:**
+- Modify: `internal/console/instancesplacement.go` (`edgesFilePlacementView` `:35-55`, beside `readEdgesFiles` `:83`)
+- Create: `internal/console/regioncandidates.go`
+- Test: `internal/console/regioncandidates_test.go`
+
+**Interfaces:**
+- Consumes: `provision.ValidRegion`, `provision.GetManifest`, `s.appPlacement`
+ (`fleets.go:479`), `openRegistry`, and `instanceRegionAllowed`
+ (`apiinstances.go:1797`).
+- Produces:
+
+```go
+func readEdgesFilesStrict(ctx context.Context, s release.Store) ([]edgesFilePlacementView, error)
+type regionCandidate struct {
+ Code string // record vocabulary; "" is the home
+ Home bool
+ AWSRegion string
+ Reachable, Hosting, Eligible bool
+}
+func regionCandidates(files []edgesFilePlacementView, homeName string) []regionCandidate
+func homeOnlyCandidates() []regionCandidate
+func (c regionCandidate) unavailableReason() string // "", "absent", "not-hosting", "not-eligible"
+func findCandidate(cands []regionCandidate, code string) (regionCandidate, bool)
+type policyState int // policyAbsent, policyKnown, policyUnreadable
+type regionPolicy struct {
+ State policyState
+ M *provision.Manifest // nil unless policyKnown
+ Placed string // fleet placement; "" when unplaced
+}
+func (s *Server) appPolicy(ctx context.Context, acct *Account, app string) regionPolicy
+func (p regionPolicy) allows(code string) bool // instanceRegionAllowed on the normalized code
+```
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/console/regioncandidates_test.go
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func edgeView(t *testing.T, body string) edgesFilePlacementView {
+ t.Helper()
+ var f edgesFilePlacementView
+ if err := json.Unmarshal([]byte(body), &f); err != nil {
+ t.Fatal(err)
+ }
+ return f
+}
+
+const (
+ homeBox = `{"v":1,"box":"ie-box","eip":"5.6.7.8","region":"eu-west-1","hibernation_configured":true,"serves_shared_pool":true,"accepts_inbound":true,"hosts":{}}`
+ ukEligible = `{"v":1,"box":"uk-box","eip":"1.2.3.4","region":"eu-west-2","placement":"uk","hibernation_configured":true,"serves_shared_pool":true,"accepts_inbound":true,"hosts":{}}`
+ auPoolOnly = `{"v":1,"box":"au-box","eip":"9.9.9.9","region":"ap-southeast-2","placement":"au","hibernation_configured":true,"serves_shared_pool":false,"accepts_inbound":true,"hosts":{}}`
+ usNoInbound = `{"v":1,"box":"us-box","eip":"8.8.4.4","region":"us-east-1","placement":"us","hibernation_configured":true,"serves_shared_pool":true,"accepts_inbound":false,"hosts":{}}`
+ caOldWriter = `{"v":1,"box":"ca-box","eip":"7.7.7.7","region":"ca-central-1","placement":"ca","hosts":{}}`
+ nzNoHosting = `{"v":1,"box":"nz-box","eip":"6.6.6.6","region":"ap-southeast-6","placement":"nz","hibernation_configured":false,"serves_shared_pool":true,"accepts_inbound":true,"hosts":{}}`
+ jpNoEIP = `{"v":1,"box":"jp-box","eip":"","region":"ap-northeast-1","placement":"jp","hibernation_configured":true,"serves_shared_pool":true,"accepts_inbound":true,"hosts":{}}`
+ fleetBoxFile = `{"v":1,"box":"fl-box","eip":"4.4.4.4","region":"eu-west-2","placement":"fleet/acme","hibernation_configured":true,"serves_shared_pool":true,"accepts_inbound":true,"hosts":{}}`
+)
+
+func TestRegionCandidatesNeedPositiveEligibility(t *testing.T) {
+ var files []edgesFilePlacementView
+ for _, b := range []string{homeBox, ukEligible, auPoolOnly, usNoInbound, caOldWriter, nzNoHosting, jpNoEIP, fleetBoxFile} {
+ files = append(files, edgeView(t, b))
+ }
+ cands := regionCandidates(files, "ie")
+ want := map[string]string{"": "", "uk": "", "au": "not-eligible", "us": "not-eligible",
+ "ca": "not-eligible", "nz": "not-hosting", "jp": "absent"}
+ if len(cands) != len(want) {
+ t.Fatalf("candidates = %+v, want exactly %v (no fleet code)", cands, want)
+ }
+ for code, reason := range want {
+ c, ok := findCandidate(cands, code)
+ if !ok {
+ t.Fatalf("no candidate %q", code)
+ }
+ if got := c.unavailableReason(); got != reason {
+ t.Errorf("%q: reason %q, want %q", code, got, reason)
+ }
+ }
+ h, _ := findCandidate(cands, "")
+ if !h.Home || h.AWSRegion != "eu-west-1" || !h.Eligible {
+ t.Errorf("home = %+v", h)
+ }
+ if _, ok := findCandidate(cands, "zz"); ok {
+ t.Error("an unpublished code was found")
+ }
+}
+
+// A box publishing the home's NAME is the home, not a second region.
+func TestRegionCandidatesFoldTheHomesName(t *testing.T) {
+ coded := `{"v":1,"box":"ie2","eip":"5.6.7.9","region":"eu-west-1","placement":"ie","hibernation_configured":true,"serves_shared_pool":true,"accepts_inbound":true,"hosts":{}}`
+ cands := regionCandidates([]edgesFilePlacementView{edgeView(t, homeBox), edgeView(t, coded)}, "ie")
+ if len(cands) != 1 || !cands[0].Home {
+ t.Fatalf("candidates = %+v, want the home alone", cands)
+ }
+}
+
+type failingList struct{ release.Store }
+
+func (failingList) List(context.Context, string) ([]string, error) {
+ return nil, errors.New("bucket unreachable")
+}
+
+func TestReadEdgesFilesStrictReportsListFailure(t *testing.T) {
+ if _, err := readEdgesFilesStrict(context.Background(), failingList{}); err == nil {
+ t.Fatal("a failed listing must be an error, not an empty answer (codex R2-2)")
+ }
+ files, err := readEdgesFilesStrict(context.Background(), release.NewDirStore(t.TempDir()))
+ if err != nil || len(files) != 0 {
+ t.Fatalf("an empty prefix = %v %v, want no files and no error", files, err)
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `go test ./internal/console -run 'TestRegionCandidates|TestReadEdgesFilesStrict' -count=1`
+Expected: FAIL, undefined.
+
+- [ ] **Step 3: Extend the view and add the strict read**
+
+In `edgesFilePlacementView`, after `HibernationConfigured`:
+
+```go
+ // ServesSharedPool and AcceptsInbound (internal/host/edgesfile.go):
+ // POINTERS for HibernationConfigured's reason — absent is a writer that
+ // predates the fields (unknown), false is a box that said no. Automatic
+ // region choices need both positively true (nearest-region spec §6.1).
+ ServesSharedPool *bool `json:"serves_shared_pool"`
+ AcceptsInbound *bool `json:"accepts_inbound"`
+```
+
+Refactor `readEdgesFiles` to share a body with the strict read:
+
+```go
+func readEdgesFiles(ctx context.Context, s release.Store) []edgesFilePlacementView {
+ if s == nil {
+ return nil
+ }
+ out, err := readEdgesFilesStrict(ctx, s)
+ if err != nil {
+ log.Printf("console: api instances: could not list %s for the placement join: %v", edgesFilePrefix, err)
+ return nil
+ }
+ return out
+}
+
+// readEdgesFilesStrict is readEdgesFiles that REPORTS a failed listing
+// instead of answering empty: the nearest-region door must tell "could not
+// read" (503, C28) from "no box publishes this code" (codex R2-2).
+// Unreadable or unparsable single files are still skipped, as before.
+func readEdgesFilesStrict(ctx context.Context, s release.Store) ([]edgesFilePlacementView, error) {
+ keys, err := s.List(ctx, edgesFilePrefix)
+ if err != nil {
+ return nil, err
+ }
+ var out []edgesFilePlacementView
+ for _, key := range keys {
+ name, ok := strings.CutPrefix(key, edgesFilePrefix)
+ if !ok || !strings.HasSuffix(name, ".json") || strings.Contains(name, "/") {
+ continue
+ }
+ body, _, err := s.Get(ctx, key)
+ if err != nil {
+ log.Printf("console: api instances: could not read %s for the placement join: %v", key, err)
+ continue
+ }
+ var f edgesFilePlacementView
+ if err := json.Unmarshal(body, &f); err != nil || f.V < 1 || f.Box == "" {
+ log.Printf("console: api instances: skipping %s in the placement join: not a versioned edges file", key)
+ continue
+ }
+ out = append(out, f)
+ }
+ return out, nil
+}
+```
+
+- [ ] **Step 4: Implement `regioncandidates.go`**
+
+```go
+package console
+
+import (
+ "context"
+ "errors"
+ "log"
+ "os"
+ "sort"
+ "sync"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// disagreeSaid throttles majority's disagreement log to once per code.
+var disagreeSaid sync.Map
+
+// regionCandidate is one region an automatic choice may land in
+// (nearest-region spec §6.1). Code is in the record vocabulary: "" is the
+// home.
+type regionCandidate struct {
+ Code string
+ Home bool
+ AWSRegion string
+ Reachable bool // some box publishing the code has a dial address
+ Hosting bool // ... and its activator configured
+ Eligible bool // ... and serves the shared pool and accepts inbound, all POSITIVELY
+}
+
+// unavailableReason is "" for an eligible candidate, else the first bar
+// it fails (the C16 vocabulary).
+func (c regionCandidate) unavailableReason() string {
+ switch {
+ case c.Eligible:
+ return ""
+ case !c.Reachable:
+ return "absent"
+ case !c.Hosting:
+ return "not-hosting"
+ }
+ return "not-eligible"
+}
+
+func isTrue(b *bool) bool { return b != nil && *b }
+
+// regionCandidates folds the edges files into candidates. Positive
+// eligibility per box: reachable, and hibernation_configured,
+// serves_shared_pool and accepts_inbound all true on ONE box. Unknown is
+// not yes here — the reverse of placements' generosity, which is right
+// for an explicit request and wrong for an automatic one (§6.1). The
+// home is exempt: it is where every legacy create already lands.
+func regionCandidates(files []edgesFilePlacementView, homeName string) []regionCandidate {
+ type acc struct {
+ c regionCandidate
+ regions map[string]int
+ }
+ by := map[string]*acc{}
+ get := func(code string, home bool) *acc {
+ a, ok := by[code]
+ if !ok {
+ a = &acc{c: regionCandidate{Code: code, Home: home}, regions: map[string]int{}}
+ by[code] = a
+ }
+ return a
+ }
+ homeA := get("", true)
+ homeA.c.Reachable, homeA.c.Hosting, homeA.c.Eligible = true, true, true
+ for _, f := range files {
+ home := f.Placement == "" || (homeName != "" && f.Placement == homeName)
+ if !home && !provision.ValidRegion(f.Placement) {
+ continue // fleet/<name> and malformed codes are never candidates
+ }
+ code := f.Placement
+ if home {
+ code = ""
+ }
+ a := get(code, home)
+ if f.EIP == "" {
+ continue
+ }
+ // Hosting keeps placements' "unknown is not no" rule (an absent
+ // hibernation_configured is a writer predating the field); only
+ // ELIGIBILITY demands all three facts positively.
+ hosting := isTrue(f.HibernationConfigured) || f.HibernationConfigured == nil
+ eligible := isTrue(f.HibernationConfigured) && isTrue(f.ServesSharedPool) && isTrue(f.AcceptsInbound)
+ if !home {
+ a.c.Reachable = true
+ a.c.Hosting = a.c.Hosting || hosting
+ a.c.Eligible = a.c.Eligible || eligible
+ }
+ if f.Region != "" && (home || eligible) {
+ a.regions[f.Region]++
+ }
+ }
+ out := make([]regionCandidate, 0, len(by))
+ for _, a := range by {
+ a.c.AWSRegion = majority(a.c.Code, a.regions)
+ out = append(out, a.c)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code })
+ return out
+}
+
+// majority picks the most common AWS region, ties to the lexically
+// smallest, and logs a disagreement once per code per process.
+func majority(code string, regions map[string]int) string {
+ best, n := "", 0
+ for r, c := range regions {
+ if c > n || (c == n && r < best) {
+ best, n = r, c
+ }
+ }
+ if len(regions) > 1 {
+ if _, said := disagreeSaid.LoadOrStore(code, true); !said {
+ log.Printf("console: boxes for region %q disagree on their AWS region %v; using %s", code, regions, best)
+ }
+ }
+ return best
+}
+
+func homeOnlyCandidates() []regionCandidate {
+ return []regionCandidate{{Code: "", Home: true, Reachable: true, Hosting: true, Eligible: true}}
+}
+
+func findCandidate(cands []regionCandidate, code string) (regionCandidate, bool) {
+ for _, c := range cands {
+ if c.Code == code {
+ return c, true
+ }
+ }
+ return regionCandidate{}, false
+}
+
+type policyState int
+
+const (
+ policyAbsent policyState = iota
+ policyKnown
+ policyUnreadable
+)
+
+// regionPolicy is what the app's manifest and placement say about where an
+// instance may land (spec §6.1, codex 6, codex R4-1).
+type regionPolicy struct {
+ State policyState
+ M *provision.Manifest
+ Placed string
+}
+
+// appPolicy reads the manifest directly — never instanceManifest, which
+// answers the caller itself — and the fleet placement. Any failure is
+// policyUnreadable: an unrestricted policy is never invented.
+func (s *Server) appPolicy(ctx context.Context, acct *Account, app string) regionPolicy {
+ var p regionPolicy
+ m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app)
+ switch {
+ case err == nil:
+ p.State, p.M = policyKnown, m
+ case errors.Is(err, os.ErrNotExist):
+ p.State = policyAbsent
+ default:
+ log.Printf("console: region policy %s/%s: read manifest: %v", acct.Sqid, app, err)
+ return regionPolicy{State: policyUnreadable}
+ }
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ log.Printf("console: region policy %s/%s: open registry: %v", acct.Sqid, app, err)
+ return regionPolicy{State: policyUnreadable}
+ }
+ placed, err := s.appPlacement(reg, acct, app)
+ if err != nil {
+ log.Printf("console: region policy %s/%s: placement: %v", acct.Sqid, app, err)
+ return regionPolicy{State: policyUnreadable}
+ }
+ p.Placed = placed
+ return p
+}
+
+// pooled reports a known manifest naming a pool.
+func (p regionPolicy) pooled() bool { return p.State == policyKnown && p.M.Pool != "" }
+
+// allows is the door's own allow-list predicate on a record-vocabulary code.
+func (p regionPolicy) allows(code string) bool {
+ if p.State != policyKnown {
+ return p.State == policyAbsent
+ }
+ return instanceRegionAllowed(p.M.Regions, code)
+}
+```
+
+Run `gofmt -l internal/console`. It must print nothing.
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `go test ./internal/console -run 'TestRegionCandidates|TestReadEdgesFilesStrict|TestPlacements' -count=1`
+Expected: PASS. In particular, `TestPlacements*` must still pass after the
+`readEdgesFiles` refactor.
+
+- [ ] **Step 6: Add the policy test**
+
+```go
+func TestAppPolicyStates(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-policy")
+ acct := &Account{ID: OpsAccountID, Sqid: "bab"}
+ ctx := context.Background()
+ if p := fx.srv.appPolicy(ctx, acct, "titogo"); p.State != policyAbsent || !p.allows("uk") {
+ t.Fatalf("no manifest = %+v, want absent and unrestricted", p)
+ }
+ m := titoManifest()
+ m.Pool, m.Regions = "", []string{"uk"}
+ enableInstances(t, fx, "titogo", m)
+ p := fx.srv.appPolicy(ctx, acct, "titogo")
+ if p.State != policyKnown || p.allows("") || !p.allows("uk") || p.pooled() {
+ t.Fatalf("known policy = %+v", p)
+ }
+ m.Pool = "tito"
+ enableInstances(t, fx, "titogo", m)
+ if !fx.srv.appPolicy(ctx, acct, "titogo").pooled() {
+ t.Fatal("a pooled manifest must read as pooled")
+ }
+ if err := fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme"); err != nil {
+ t.Fatal(err)
+ }
+ if p := fx.srv.appPolicy(ctx, acct, "titogo"); p.Placed != "fleet/acme" {
+ t.Fatalf("placed = %q", p.Placed)
+ }
+}
+```
+
+Run: `go test ./internal/console -run TestAppPolicyStates -count=1`
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/console/instancesplacement.go internal/console/regioncandidates.go internal/console/regioncandidates_test.go
+git commit -m "console: region candidates with positive per-box eligibility, strict edges read, policy states (nearest-region §6.1)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 11: The guess and its invariant
+
+**Files:**
+- Create: `internal/console/regionguess.go`
+- Test: `internal/console/regionguess_test.go`
+
+**Interfaces:**
+- Consumes: `regionCandidate`, `regionPolicy.allows` (Task 10), `geo.ZonePoint`,
+ `geo.Nearest`, `geo.Candidate` and `geo.Locator` (Tasks 2 to 5).
+- Produces:
+
+```go
+type guessInput struct{ TZ string; IP net.IP }
+type guessResult struct{ Code, Source string } // Code: record vocabulary
+func guessRegion(cands []regionCandidate, allows func(code string) bool, in guessInput, loc geo.Locator) (guessResult, bool)
+```
+
+- [ ] **Step 1: Write the failing tests, including the property test**
+
+```go
+// internal/console/regionguess_test.go
+package console
+
+import (
+ "math/rand/v2"
+ "net"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/geo"
+)
+
+type fakeLocator map[string]geo.Point
+
+func (f fakeLocator) Locate(ip net.IP) (geo.Point, bool) {
+ p, ok := f[ip.String()]
+ return p, ok
+}
+
+func allowAll(string) bool { return true }
+
+var (
+ cHome = regionCandidate{Code: "", Home: true, AWSRegion: "eu-west-1", Reachable: true, Hosting: true, Eligible: true}
+ cUK = regionCandidate{Code: "uk", AWSRegion: "eu-west-2", Reachable: true, Hosting: true, Eligible: true}
+ cAU = regionCandidate{Code: "au", AWSRegion: "ap-southeast-2", Reachable: true, Hosting: true, Eligible: true}
+)
+
+func TestGuessRegionOrder(t *testing.T) {
+ loc := fakeLocator{"81.2.69.142": {-33.9, 151.2}} // an IP that locates to Sydney
+ cands := []regionCandidate{cHome, cUK, cAU}
+ cases := []struct {
+ name string
+ in guessInput
+ want guessResult
+ }{
+ {"timezone first", guessInput{TZ: "Europe/London", IP: net.ParseIP("81.2.69.142")}, guessResult{"uk", "timezone"}},
+ {"UTC falls to IP", guessInput{TZ: "Etc/UTC", IP: net.ParseIP("81.2.69.142")}, guessResult{"au", "ip"}},
+ {"nothing maps", guessInput{TZ: "Etc/UTC"}, guessResult{"", "default"}},
+ }
+ for _, c := range cases {
+ got, ok := guessRegion(cands, allowAll, c.in, loc)
+ if !ok || got != c.want {
+ t.Errorf("%s: got (%+v, %v), want %+v", c.name, got, ok, c.want)
+ }
+ }
+ // An ineligible candidate nearer than every eligible one is never picked.
+ auOff := cAU
+ auOff.Eligible = false
+ got, _ := guessRegion([]regionCandidate{cHome, cUK, auOff}, allowAll, guessInput{TZ: "Australia/Sydney"}, nil)
+ if got.Code == "au" {
+ t.Fatal("picked an ineligible region")
+ }
+}
+
+// Spec §6.4, codex R3-4: an allow-list of only uk, with the home's
+// coordinates missing, gives uk when the hint maps and null when not.
+func TestGuessRegionNeverReturnsADisallowedHome(t *testing.T) {
+ noPointHome := cHome
+ noPointHome.AWSRegion = ""
+ onlyUK := func(code string) bool { return code == "uk" }
+ cands := []regionCandidate{noPointHome, cUK}
+ if got, ok := guessRegion(cands, onlyUK, guessInput{TZ: "Europe/London"}, nil); !ok || got.Code != "uk" {
+ t.Fatalf("mapped hint = (%+v, %v), want uk", got, ok)
+ }
+ if got, ok := guessRegion(cands, onlyUK, guessInput{TZ: "Etc/UTC"}, nil); ok {
+ t.Fatalf("unmappable hint = %+v, want no guess (never the excluded home)", got)
+ }
+ if got, ok := guessRegion(homeOnlyCandidates(), onlyUK, guessInput{TZ: "Europe/London"}, nil); ok {
+ t.Fatalf("edges-read failure = %+v, want no guess", got)
+ }
+}
+
+// The invariant over generated policies, candidate sets, missing
+// coordinates and hints: every non-null guess passes the policy, and a
+// home fallback appears only when the home is allowed.
+func TestGuessRegionInvariantProperty(t *testing.T) {
+ codes := []string{"", "uk", "au", "us", "jp"}
+ regions := []string{"eu-west-1", "eu-west-2", "ap-southeast-2", "us-east-1", "", "xx-nowhere-1"}
+ zones := []string{"Europe/London", "Australia/Sydney", "America/New_York", "Etc/UTC", "", "Asia/Tokyo"}
+ r := rand.New(rand.NewPCG(1, 2))
+ for i := 0; i < 20000; i++ {
+ var cands []regionCandidate
+ for _, code := range codes {
+ if code != "" && r.IntN(3) == 0 {
+ continue
+ }
+ cands = append(cands, regionCandidate{Code: code, Home: code == "",
+ AWSRegion: regions[r.IntN(len(regions))], Reachable: true, Hosting: true,
+ Eligible: code == "" || r.IntN(4) != 0})
+ }
+ allowed := map[string]bool{}
+ for _, code := range codes {
+ allowed[code] = r.IntN(2) == 0
+ }
+ allows := func(code string) bool { return allowed[code] }
+ got, ok := guessRegion(cands, allows, guessInput{TZ: zones[r.IntN(len(zones))]}, nil)
+ if !ok {
+ continue
+ }
+ if !allows(got.Code) {
+ t.Fatalf("case %d: guess %+v fails the policy %v", i, got, allowed)
+ }
+ c, found := findCandidate(cands, got.Code)
+ if !found || !c.Eligible {
+ t.Fatalf("case %d: guess %+v is not an eligible candidate", i, got)
+ }
+ }
+}
+
+// Review Focus 4's core half: a malformed hint is absent, never a panic.
+func TestGuessRegionToleratesGarbageHints(t *testing.T) {
+ big := make([]byte, 10<<10)
+ for i := range big {
+ big[i] = 'a'
+ }
+ got, ok := guessRegion([]regionCandidate{cHome, cUK}, allowAll, guessInput{TZ: string(big)}, fakeLocator{})
+ if !ok || got != (guessResult{"", "default"}) {
+ t.Fatalf("garbage tz = (%+v, %v), want the home default", got, ok)
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `go test ./internal/console -run TestGuessRegion -count=1`
+Expected: FAIL, undefined `guessRegion`.
+
+- [ ] **Step 3: Implement `regionguess.go`**
+
+```go
+package console
+
+import (
+ "net"
+
+ "github.com/carlosframework/platform/internal/geo"
+)
+
+// guessInput carries a request's location hints for ONE call. Neither
+// field is ever stored, logged, audited or echoed (spec §6.5).
+type guessInput struct {
+ TZ string
+ IP net.IP
+}
+
+// guessResult is a guess in the record vocabulary ("" is the home) and its
+// source word: timezone | ip | default.
+type guessResult struct{ Code, Source string }
+
+// guessRegion is spec §6.4:
+//
+// 1. filter candidates to eligible ones the policy allows;
+// 2. nearest among those with a point, from the timezone, else the IP;
+// 3. else the home, ONLY if the home is itself a filtered candidate;
+// 4. else no guess.
+//
+// So every non-null answer, the home fallback included, passes allows
+// (codex R3-4). geo.Nearest refuses when the home is a candidate with no
+// point, which is what sends a mapped hint to the home default then.
+func guessRegion(cands []regionCandidate, allows func(code string) bool, in guessInput, loc geo.Locator) (guessResult, bool) {
+ var pick []geo.Candidate
+ homeIn := false
+ for _, c := range cands {
+ if !c.Eligible || !allows(c.Code) {
+ continue
+ }
+ pick = append(pick, geo.Candidate{Code: c.Code, Home: c.Home, AWSRegion: c.AWSRegion})
+ homeIn = homeIn || c.Home
+ }
+ if len(pick) == 0 {
+ return guessResult{}, false
+ }
+ if p, ok := geo.ZonePoint(in.TZ); ok {
+ if code, ok := geo.Nearest(p, pick); ok {
+ return guessResult{code, "timezone"}, true
+ }
+ } else if loc != nil && in.IP != nil {
+ if p, ok := loc.Locate(in.IP); ok {
+ if code, ok := geo.Nearest(p, pick); ok {
+ return guessResult{code, "ip"}, true
+ }
+ }
+ }
+ if homeIn {
+ return guessResult{"", "default"}, true
+ }
+ return guessResult{}, false
+}
+```
+
+R3 says IP is the fallback when the timezone is unmappable. A mapped timezone
+whose nearest pick fails (because the home has no point) falls to the home
+default, not to IP. The `else if` encodes that.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test ./internal/console -run TestGuessRegion -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/regionguess.go internal/console/regionguess_test.go
+git commit -m "console: the region guess, with its policy invariant property-tested (nearest-region §6.4)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 12: `claimApp` resolution and the API create door
+
+**Files:**
+- Create: `internal/console/regionclaim.go`
+- Modify: `internal/console/server.go:3466-3567` (`claimApp`), `:3626` (browser call site: passes `regionReq{}` for now; Task 13 fills it)
+- Modify: `internal/console/apicli.go:330-470` (`handleAPICreateApp`)
+- Modify: `internal/console/apps_claim_test.go:118,134,150,165`, `internal/console/apicli_test.go:2504` (add the `regionReq{}` argument)
+- Test: `internal/console/regionclaim_test.go`
+
+**Interfaces:**
+- Consumes: Tasks 8 to 11, `s.clientIP` (`authcli.go:876`), `principalFrom`,
+ `placements`, `hostingSet`, `homeRegionIn` and `homeRegionOut`.
+- Produces:
+
+```go
+type regionReq struct {
+ explicit string // a person's choice, record vocabulary
+ hasExplicit bool
+ tz string
+ ip net.IP // person requests only
+}
+func (s *Server) validateExplicitRegion(ctx context.Context, raw string) (code string, ref *doorRefusal)
+func (s *Server) resolveNewAppRegion(ctx context.Context, rq regionReq, adopting bool) (region, source string)
+func (s *Server) placeOf(code string, cands []regionCandidate) string // a place name, or the code under its name
+func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, rq regionReq) (claimOutcome, string, *apexHold)
+```
+
+`handleAPICreateApp`'s response gains `region`, `region_name`,
+`region_source` and `regions_available`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/console/regionclaim_test.go
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "net"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func regionFixture(t *testing.T) (*consoleFixture, string) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion = true, "ie"
+ putEdges(t, fx, map[string]string{"ie-box": homeBox, "uk-box": ukEligible})
+ return fx, cliToken(t, fx, "paul@keymail.dev", "code-region-claim")
+}
+
+func createApp(t *testing.T, fx *consoleFixture, token string, body map[string]string) (int, map[string]any) {
+ t.Helper()
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", body, token)
+ var res map[string]any
+ json.Unmarshal([]byte(out), &res)
+ return status, res
+}
+
+func TestAPICreateAppResolvesRegion(t *testing.T) {
+ fx, token := regionFixture(t)
+ status, res := createApp(t, fx, token, map[string]string{"name": "blog", "timezone": "Europe/London"})
+ if status != http.StatusOK || res["region"] != "uk" || res["region_source"] != "timezone" ||
+ res["region_name"] != "UK · London" || res["regions_available"] != float64(2) {
+ t.Fatalf("create = %d %v", status, res)
+ }
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, "blog"); r != "uk" || src != "timezone" {
+ t.Fatalf("stored %q %q", r, src)
+ }
+ // An old CLI's body: no timezone, no region. Localhost has no IP to
+ // locate, so it is the home, named.
+ _, res = createApp(t, fx, token, map[string]string{"name": "old"})
+ if res["region"] != "ie" || res["region_source"] != "default" {
+ t.Fatalf("old body = %v, want ie/default", res)
+ }
+ // An explicit home name is a choice, stored as "".
+ _, res = createApp(t, fx, token, map[string]string{"name": "athome", "region": "ie"})
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, "athome"); r != "" || src != "chosen" || res["region"] != "ie" {
+ t.Fatalf("explicit home = %q %q %v", r, src, res)
+ }
+}
+
+func TestAPICreateAppRefusesBadExplicitRegions(t *testing.T) {
+ fx, token := regionFixture(t)
+ putEdges(t, fx, map[string]string{"nz-box": nzNoHosting})
+ for region, want := range map[string]string{
+ "Bad/Code": msgRegionUnknown("Bad/Code", []string{"ie", "nz", "uk"}),
+ "nz": msgRegionNotArmed("nz", []string{"ie", "uk"}),
+ } {
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "x" + strings.ToLower(region[:1]), "region": region}, token)
+ if status != http.StatusBadRequest || !strings.Contains(out, want) {
+ t.Errorf("region %q = %d %s, want 400 %q", region, status, out, want)
+ }
+ }
+ if has, _ := fx.st.HasAppIn(OpsAccountID, "xb"); has {
+ t.Fatal("a refused region still claimed the name")
+ }
+}
+
+func TestAPICreateAppSwitchOffStoresTheHome(t *testing.T) {
+ fx, token := regionFixture(t)
+ fx.srv.cfg.AppRegions = false
+ _, res := createApp(t, fx, token, map[string]string{"name": "off", "region": "uk", "timezone": "Europe/London"})
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, "off"); r != "" || src != "default" || res["region"] != "ie" {
+ t.Fatalf("switch off = %q %q %v", r, src, res)
+ }
+ fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion = true, ""
+ createApp(t, fx, token, map[string]string{"name": "unnamed", "region": "uk"})
+ if r, _, _ := fx.st.AppRegion(OpsAccountID, "unnamed"); r != "" {
+ t.Fatalf("unnamed home stored %q", r)
+ }
+}
+
+// Adoption never changes an existing app's region by guessing (§5.5).
+func TestClaimAdoptionDiscardsTheGuess(t *testing.T) {
+ fx, _ := regionFixture(t)
+ acct := &Account{ID: OpsAccountID, Sqid: "bab"}
+ seedBucketApp(t, fx, "bab", "oldsite")
+ out, _, _ := fx.srv.claimApp(context.Background(), acct, "oldsite", "paul@keymail.dev", "", true,
+ regionReq{tz: "Europe/London"})
+ if out != claimOK {
+ t.Fatalf("adoption = %v", out)
+ }
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, "oldsite"); r != "" || src != "default" {
+ t.Fatalf("adoption with a guess = %q %q, want the home", r, src)
+ }
+ seedBucketApp(t, fx, "bab", "oldsite2")
+ fx.srv.claimApp(context.Background(), acct, "oldsite2", "paul@keymail.dev", "", true,
+ regionReq{explicit: "uk", hasExplicit: true})
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, "oldsite2"); r != "uk" || src != "chosen" {
+ t.Fatalf("adoption with an explicit choice = %q %q", r, src)
+ }
+}
+
+// A service principal is a server: its own address is never located.
+func TestResolveNewAppRegionIgnoresIPWithoutAPerson(t *testing.T) {
+ fx, _ := regionFixture(t)
+ fx.srv.cfg.GeoIP = fakeLocator{"81.2.69.142": {51.5, -0.12}}
+ r, src := fx.srv.resolveNewAppRegion(context.Background(), regionReq{}, false)
+ if r != "" || src != "default" {
+ t.Fatalf("no ip = %q %q", r, src)
+ }
+ r, src = fx.srv.resolveNewAppRegion(context.Background(), regionReq{ip: net.ParseIP("81.2.69.142")}, false)
+ if r != "uk" || src != "ip" {
+ t.Fatalf("person ip = %q %q", r, src)
+ }
+}
+
+// Review Focus 5: a claim that fails after resolution writes no row and no
+// region log line.
+func TestFailedClaimWritesNoRegion(t *testing.T) {
+ fx, token := regionFixture(t)
+ createApp(t, fx, token, map[string]string{"name": "taken", "region": "uk"})
+ var logs strings.Builder
+ restore := captureLog(&logs)
+ status, _ := createApp(t, fx, token, map[string]string{"name": "taken", "region": "ie"})
+ restore()
+ if status != http.StatusConflict {
+ t.Fatalf("second claim = %d", status)
+ }
+ if r, _, _ := fx.st.AppRegion(OpsAccountID, "taken"); r != "uk" {
+ t.Fatalf("a failed claim changed the region to %q", r)
+ }
+ if strings.Contains(logs.String(), "app region bab/taken") {
+ t.Fatalf("a failed claim logged a region line: %s", logs.String())
+ }
+}
+```
+
+Add a `captureLog` helper to `regionclaim_test.go` if the package has none
+(`grep -n 'func captureLog' internal/console/*_test.go`):
+
+```go
+func captureLog(w io.Writer) (restore func()) {
+ prev, flags := log.Writer(), log.Flags()
+ log.SetOutput(w)
+ return func() { log.SetOutput(prev); log.SetFlags(flags) }
+}
+```
+
+Importing `io` and `log` is needed only if you add the helper.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `go test ./internal/console -run 'TestAPICreateApp|TestClaimAdoption|TestResolveNewAppRegion|TestFailedClaim' -count=1`
+Expected: FAIL to compile: `claimApp` has the wrong arity, and `regionReq` is
+undefined.
+
+- [ ] **Step 3: Implement `regionclaim.go`**
+
+```go
+package console
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "sort"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/geo"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// regionReq is what a create door hands claimApp: never a resolved region.
+// The guess is resolved INSIDE claimApp, after it knows whether this is an
+// adoption (spec §5.5, codex 2, codex R2-5). ip is request-local and only
+// ever set for a person's request; it is never logged or stored.
+type regionReq struct {
+ explicit string
+ hasExplicit bool
+ tz string
+ ip net.IP
+}
+
+// validateExplicitRegion checks a person's explicit region the way the
+// instance door checks one (spec §8.1): ValidRegion, then armed and
+// hosting unless the gate is dormant; the home's name normalizes to "".
+func (s *Server) validateExplicitRegion(ctx context.Context, raw string) (string, *doorRefusal) {
+ code := s.homeRegionIn(strings.TrimSpace(raw))
+ if code == "" {
+ return "", nil
+ }
+ caps, published := placements(ctx, s.cfg.Releases)
+ if !provision.ValidRegion(code) {
+ return "", &doorRefusal{http.StatusBadRequest, msgRegionUnknown(code, s.namedCodes(caps))}
+ }
+ if published && !(caps[code].Reachable && caps[code].Hosting) {
+ return "", &doorRefusal{http.StatusBadRequest, msgRegionNotArmed(code, s.namedCodes(hostingCaps(caps)))}
+ }
+ return code, nil
+}
+
+// namedCodes lists the home's name plus every code in caps, sorted.
+func (s *Server) namedCodes(caps map[string]placementCapability) []string {
+ set := map[string]bool{}
+ if s.cfg.HomeRegion != "" {
+ set[s.cfg.HomeRegion] = true
+ }
+ for code := range caps {
+ if provision.ValidRegion(code) {
+ set[code] = true
+ }
+ }
+ out := make([]string, 0, len(set))
+ for c := range set {
+ out = append(out, c)
+ }
+ sort.Strings(out)
+ return out
+}
+
+func hostingCaps(caps map[string]placementCapability) map[string]placementCapability {
+ out := map[string]placementCapability{}
+ for code, c := range caps {
+ if c.Reachable && c.Hosting {
+ out[code] = c
+ }
+ }
+ return out
+}
+
+// resolveNewAppRegion is spec §5.5 and §5.6 for a NEW row.
+func (s *Server) resolveNewAppRegion(ctx context.Context, rq regionReq, adopting bool) (region, source string) {
+ if !s.appRegionsOn() {
+ return "", "default"
+ }
+ if rq.hasExplicit {
+ return rq.explicit, "chosen"
+ }
+ if adopting {
+ return "", "default"
+ }
+ files, err := readEdgesFilesStrict(ctx, s.cfg.Releases)
+ if err != nil {
+ return "", "default"
+ }
+ g, ok := guessRegion(regionCandidates(files, s.cfg.HomeRegion), func(string) bool { return true },
+ guessInput{TZ: rq.tz, IP: rq.ip}, s.cfg.GeoIP)
+ if !ok {
+ return "", "default"
+ }
+ return g.Code, g.Source
+}
+
+// placeOf names a record-vocabulary code: its AWS region's place, else the
+// code under its name.
+func (s *Server) placeOf(code string, cands []regionCandidate) string {
+ c, _ := findCandidate(cands, code)
+ return geo.PlaceName(c.AWSRegion, s.homeRegionOut(code))
+}
+
+// personIP is the request's own address, for a person only (spec §6.2).
+func (s *Server) personIP(r *http.Request) net.IP {
+ if p, ok := principalFrom(r.Context()); ok && p.Kind != PrincipalUser {
+ return nil
+ }
+ return net.ParseIP(s.clientIP(r))
+}
+
+func eligibleCount(cands []regionCandidate) int {
+ n := 0
+ for _, c := range cands {
+ if c.Eligible {
+ n++
+ }
+ }
+ return n
+}
+```
+
+- [ ] **Step 4: Thread `regionReq` through `claimApp`**
+
+Change the signature, and replace the `CreateAppIn` call at `server.go:3550`:
+
+```go
+func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, rq regionReq) (outcome claimOutcome, placed string, held *apexHold) {
+```
+
+```go
+ region, source := s.resolveNewAppRegion(ctx, rq, adoptingExistingApp)
+ created, err := s.store.CreateAppInRegion(acct.ID, name, addr, region, source, time.Now())
+ if err != nil {
+ return claimCreateFailed, "", nil
+ }
+ if !created { // lost a claim race after the checks
+ return claimTaken, "", nil
+ }
+ if s.appRegionsOn() {
+ log.Printf("console: app region %s/%s: %s (%s)", acct.Sqid, name, s.homeRegionOut(region), source)
+ }
+```
+
+The region is resolved here, after every refusal above it, so a refused
+claim resolves nothing and logs nothing (Review Focus 5). Update the existing
+callers and tests by adding `, regionReq{}` as the last argument:
+`server.go:3626`, `apps_claim_test.go:118,134,150,165` and
+`apicli_test.go:2504`.
+
+- [ ] **Step 5: Extend `handleAPICreateApp`**
+
+Add the fields to the request struct:
+
+```go
+ Region string `json:"region"`
+ Timezone string `json:"timezone"`
+```
+
+After the `place` validation block and before `ownerClaim`:
+
+```go
+ rq := regionReq{tz: req.Timezone, ip: s.personIP(r)}
+ if strings.TrimSpace(req.Region) != "" {
+ code, ref := s.validateExplicitRegion(r.Context(), req.Region)
+ if ref != nil {
+ jsonError(w, ref.Status, ref.Msg)
+ return
+ }
+ rq.explicit, rq.hasExplicit = code, true
+ }
+```
+
+Pass `rq` as the last argument of `claimApp`. In the `claimOK` branch, read
+the region back and add it to the response map:
+
+```go
+ gotRegion, gotSource, err := s.store.AppRegion(acct.ID, name)
+ if err != nil {
+ log.Printf("console: claim %s/%s: read back region: %v", acct.Sqid, name, err)
+ }
+ files, _ := readEdgesFilesStrict(r.Context(), s.cfg.Releases)
+ cands := regionCandidates(files, s.cfg.HomeRegion)
+```
+
+```go
+ "region": s.homeRegionOut(gotRegion),
+ "region_name": s.placeOf(gotRegion, cands),
+ "region_source": gotSource,
+ "regions_available": eligibleCount(cands),
+```
+
+When `files` failed to read, `cands` holds only the home, so
+`regions_available` is 1 and the CLI stays silent.
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+Run: `go test ./internal/console -count=1`
+Expected: PASS, including every existing claim test.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git 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
+git commit -m "console: resolve the app region inside claimApp; API create takes region and timezone (nearest-region §5.5, §8.1, §9.1)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 13: The new-app page picker, POST and browser guess route
+
+**Files:**
+- Create: `internal/console/regionpicker.go`
+- Modify: `internal/console/server.go` (`page` struct `:1350`, `handleNewAppForm` `:3586`, `handleNewApp` `:3607`, routes `:1152-1153`)
+- Modify: `internal/console/templates/app_new.html`
+- Test: `internal/console/regionpicker_test.go`
+
+**Interfaces:**
+- Consumes: Tasks 6, 10, 11 and 12.
+- Produces:
+
+```go
+type regionOption struct{ Value, Label string }
+type regionPickerView struct{ Auto string; Options []regionOption; Attribution bool }
+func (s *Server) newAppPicker(ctx context.Context, ip net.IP) *regionPickerView // nil = no picker
+func (s *Server) handleNewAppRegion(w http.ResponseWriter, r *http.Request) // POST /a/{sqid}/apps/new/region
+const autoRegion = "@auto"
+```
+
+The `page` struct gains the field `RegionPicker *regionPickerView`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/console/regionpicker_test.go
+package console
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+func TestNewAppPageWithoutPickerIsUnchanged(t *testing.T) {
+ fx := newConsoleFixture(t) // switch off
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/new"))
+ if strings.Contains(body, `name="region"`) {
+ t.Fatal("switch off rendered a region picker")
+ }
+}
+
+func TestNewAppPickerAndAutoChoice(t *testing.T) {
+ fx, _ := regionFixture(t)
+ c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-picker")
+ _, _, body := get(t, c, fx.u("/apps/new"))
+ for _, want := range []string{`name="region"`, `value="@auto"`, `value="uk"`, `value="ie"`, "UK · London", regionCopy.PickerLabel} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("picker missing %q", want)
+ }
+ }
+ // @auto with a London timezone resolves server-side.
+ postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"auto1"}, "region": {"@auto"}, "tz": {"Europe/London"}})
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, "auto1"); r != "uk" || src != "timezone" {
+ t.Fatalf("@auto = %q %q", r, src)
+ }
+ // A code is a choice.
+ postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"chose"}, "region": {"ie"}, "tz": {"Europe/London"}})
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, "chose"); r != "" || src != "chosen" {
+ t.Fatalf("explicit ie = %q %q", r, src)
+ }
+}
+
+// A region and a home both NAMED "auto" stay distinguishable from @auto
+// (codex R2-3).
+func TestRegionNamedAutoIsNotTheSentinel(t *testing.T) {
+ fx, _ := regionFixture(t)
+ fx.srv.cfg.HomeRegion = "auto"
+ c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-auto-name")
+ postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"homeauto"}, "region": {"auto"}})
+ if r, src, _ := fx.st.AppRegion(OpsAccountID, "homeauto"); r != "" || src != "chosen" {
+ t.Fatalf("explicit home named auto = %q %q, want a choice of the home", r, src)
+ }
+}
+
+// Review Focus 3: a region that became ineligible between render and POST.
+func TestNewAppPostWithANoLongerEligibleRegion(t *testing.T) {
+ fx, _ := regionFixture(t)
+ c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-stale")
+ get(t, c, fx.u("/apps/new"))
+ putEdges(t, fx, map[string]string{"uk-box": auPoolOnly}) // uk-box's file now says something else
+ status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"stale"}, "region": {"uk"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/new") {
+ t.Fatalf("stale region = %d %q, want back to the form", status, loc)
+ }
+ if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, regionCopy.Unavailable) {
+ t.Fatal("no C5 flash")
+ }
+ if has, _ := fx.st.HasAppIn(OpsAccountID, "stale"); has {
+ t.Fatal("a refused region still created the app")
+ }
+}
+
+func TestNewAppRegionRouteNeedsSessionAndCSRF(t *testing.T) {
+ fx, _ := regionFixture(t)
+ c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-route")
+ status, _, body := postForm(t, c, fx.u("/apps/new/region"), url.Values{"csrf": {csrf}, "tz": {"Europe/London"}})
+ var res map[string]string
+ json.Unmarshal([]byte(body), &res)
+ if status != http.StatusOK || res["code"] != "uk" || res["source"] != "timezone" || res["name"] != "UK · London" {
+ t.Fatalf("guess route = %d %s", status, body)
+ }
+ if status, _, _ := postForm(t, c, fx.u("/apps/new/region"), url.Values{"csrf": {"wrong"}, "tz": {"Europe/London"}}); status != http.StatusForbidden {
+ t.Fatalf("bad csrf = %d, want 403", status)
+ }
+ if status, _, _ := postForm(t, newBrowser(t), fx.u("/apps/new/region"), url.Values{"tz": {"Europe/London"}}); status == http.StatusOK {
+ t.Fatal("no session got a guess")
+ }
+}
+```
+
+`signInPaul` (`server_test.go:450`) returns just the signed-in client.
+`signInAs` (`svcpage_test.go:59`) also returns the CSRF token.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `go test ./internal/console -run 'TestNewApp|TestRegionNamedAuto' -count=1`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement `regionpicker.go`**
+
+```go
+package console
+
+import (
+ "context"
+ "crypto/subtle"
+ "fmt"
+ "net"
+ "net/http"
+ "strings"
+)
+
+// autoRegion is the picker's "let the server guess" value. "@" is outside
+// the placement-code grammar ^[a-z0-9-]{0,16}$, so no real region — not
+// even one named "auto" — can collide with it (codex R2-3).
+const autoRegion = "@auto"
+
+type regionOption struct{ Value, Label string }
+
+type regionPickerView struct {
+ Auto string // the @auto option's label (C3)
+ Options []regionOption
+ Attribution bool // C24, when the IP step is configured
+}
+
+// newAppPicker builds the new-app picker, or nil for no picker: switch
+// off, an edges read failure, or fewer than two eligible regions (§10).
+// The server's guess here is IP, then home: a render cannot see the
+// browser's timezone; the page script relabels the @auto option.
+func (s *Server) newAppPicker(ctx context.Context, ip net.IP) *regionPickerView {
+ if !s.appRegionsOn() {
+ return nil
+ }
+ files, err := readEdgesFilesStrict(ctx, s.cfg.Releases)
+ if err != nil {
+ return nil
+ }
+ cands := regionCandidates(files, s.cfg.HomeRegion)
+ if eligibleCount(cands) < 2 {
+ return nil
+ }
+ g, _ := guessRegion(cands, func(string) bool { return true }, guessInput{IP: ip}, s.cfg.GeoIP)
+ v := &regionPickerView{
+ Auto: fmt.Sprintf(regionCopy.AutoOption, s.placeOf(g.Code, cands)),
+ Attribution: s.cfg.GeoIP != nil,
+ }
+ for _, c := range cands {
+ if c.Eligible {
+ v.Options = append(v.Options, regionOption{Value: s.homeRegionOut(c.Code), Label: s.placeOf(c.Code, cands)})
+ }
+ }
+ return v
+}
+
+// pickedRegion turns the form's region field into a regionReq. ok=false
+// means the submitted code is not an eligible region now (C5).
+func (s *Server) pickedRegion(ctx context.Context, r *http.Request) (regionReq, bool) {
+ rq := regionReq{tz: r.FormValue("tz"), ip: s.personIP(r)}
+ v := strings.TrimSpace(r.FormValue("region"))
+ if v == "" || v == autoRegion {
+ return rq, true
+ }
+ files, err := readEdgesFilesStrict(ctx, s.cfg.Releases)
+ if err != nil {
+ return rq, false
+ }
+ code := s.homeRegionIn(v)
+ c, found := findCandidate(regionCandidates(files, s.cfg.HomeRegion), code)
+ if !found || !c.Eligible {
+ return rq, false
+ }
+ rq.explicit, rq.hasExplicit = code, true
+ return rq, true
+}
+
+// handleNewAppRegion is the page script's guess (spec §9.5): a POST with
+// csrf and tz, session-gated, writing nothing. The hint never leaves the
+// request (§6.5).
+func (s *Server) handleNewAppRegion(w http.ResponseWriter, r *http.Request) {
+ _, raw, _, _, ok := s.account(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ jsonError(w, http.StatusForbidden, "Cross-site request blocked.")
+ return
+ }
+ files, err := readEdgesFilesStrict(r.Context(), s.cfg.Releases)
+ cands := homeOnlyCandidates()
+ if err == nil {
+ cands = regionCandidates(files, s.cfg.HomeRegion)
+ }
+ g, ok := guessRegion(cands, func(string) bool { return true }, guessInput{TZ: r.FormValue("tz"), IP: s.personIP(r)}, s.cfg.GeoIP)
+ if !ok {
+ g = guessResult{"", "default"}
+ }
+ writeJSON(w, http.StatusOK, map[string]string{
+ "code": s.homeRegionOut(g.Code), "name": s.placeOf(g.Code, cands), "source": g.Source,
+ })
+}
+```
+
+The CSRF refusal body reuses the console's existing words for this refusal,
+from `handleNewApp`. If copy review renames that string, use the new text in
+both places.
+
+- [ ] **Step 4: Wire the routes, the page field, the GET and the POST**
+
+Next to the existing `apps/new` routes at `server.go:1152-1153`:
+
+```go
+ s.mux.HandleFunc("POST /a/{sqid}/apps/new/region", s.handleNewAppRegion)
+```
+
+In `page`: `RegionPicker *regionPickerView`.
+
+In `handleNewAppForm`, before `render`:
+
+```go
+ p.RegionPicker = s.newAppPicker(r.Context(), s.personIP(r))
+```
+
+In `handleNewApp`, after the CSRF check and the `name` read:
+
+```go
+ rq, regionOK := s.pickedRegion(r.Context(), r)
+ if !regionOK {
+ fail(regionCopy.Unavailable)
+ return
+ }
+ outcome, _, held := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner, rq)
+```
+
+In the `claimOK` branch, replace the audit line:
+
+```go
+ detail := "created app " + name
+ if s.appRegionsOn() {
+ region, _, _ := s.store.AppRegion(acct.ID, name)
+ files, _ := readEdgesFilesStrict(r.Context(), s.cfg.Releases)
+ detail = fmt.Sprintf(regionCopy.AuditCreated, name, s.placeOf(region, regionCandidates(files, s.cfg.HomeRegion)))
+ }
+ s.audit(acct, name, addr, "create", detail)
+```
+
+- [ ] **Step 5: Add the picker to `app_new.html`**
+
+Inside the `<form>`, after the name `<input>` and before the button:
+
+```html
+ {{with .RegionPicker}}
+ <label for="app-region">{{$.RegionCopy.PickerLabel}}</label>
+ <select id="app-region" name="region">
+ <option value="@auto" selected>{{.Auto}}</option>
+ {{range .Options}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
+ </select>
+ <input type="hidden" name="tz" value="">
+ <p class="small">{{$.RegionCopy.PickerHelp}}</p>
+ {{if .Attribution}}<p class="small"><a href="https://db-ip.com">{{$.RegionCopy.Attribution}}</a></p>{{end}}
+ <script>
+ // Enhancement only: with JS off the server's IP-or-home guess stands.
+ // Fills the tz hint and relabels ONLY the @auto option; its value stays
+ // "@auto" so the server still knows the person did not choose.
+ (function () {
+ var f = document.currentScript.closest("form"), sel = f.elements.region, tz = "";
+ try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ""; } catch (e) {}
+ f.elements.tz.value = tz;
+ if (!tz) return;
+ var body = new URLSearchParams({ csrf: f.elements.csrf.value, tz: tz });
+ fetch("{{$.Base}}/apps/new/region", { method: "POST", body: body, credentials: "same-origin" })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (g) {
+ if (!g || sel.value !== "@auto") return;
+ sel.options[0].text = sel.options[0].text.replace(/^.*?( \([^)]*\))$/, g.name + "$1");
+ })
+ .catch(function () {});
+ })();
+ </script>
+ {{end}}
+```
+
+The relabel keeps C3's suffix and swaps in the guessed place. If copy review
+changes C3's shape, replace the regex with
+`sel.options[0].text = "{{printf $.RegionCopy.AutoOption "%PLACE%"}}".replace("%PLACE%", g.name);`.
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+Run: `go test ./internal/console -count=1`
+Expected: PASS, including every existing new-app test (the switch-off form is
+unchanged).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/console/regionpicker.go internal/console/regionpicker_test.go internal/console/server.go internal/console/templates/app_new.html
+git commit -m "console: new-app region picker with @auto, browser guess route (nearest-region §10, §9.5)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 14: The instance create door: fill, eligibility and response
+
+**Files:**
+- Modify: `internal/console/apiinstances.go` (`apiRecordView` `:535`, `:763-799`, `:1066`)
+- Test: `internal/console/regionfill_test.go`
+
+**Interfaces:**
+- Consumes: Tasks 7, 8 and 10, `s.store.AppRegion`, and `findCandidate`.
+- Produces: `apiRecordView.RegionFrom string` (`json:"region_from,omitempty"`)
+ and `apiRecordView.RegionName string` (`json:"region_name,omitempty"`).
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/console/regionfill_test.go
+package console
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+type fillRec struct {
+ provision.Record
+ RegionFrom string `json:"region_from"`
+ RegionName string `json:"region_name"`
+}
+
+func fillFixture(t *testing.T, appRegion string) (*consoleFixture, string, string) {
+ t.Helper()
+ fx, token := instanceFixture(t, "code-fill-"+appRegion)
+ fx.srv.cfg.HomeRegion = "ie"
+ m := titoManifest()
+ m.Pool, m.Regions = "", nil
+ enableInstances(t, fx, "titogo", m)
+ if _, err := fx.st.db.Exec(`UPDATE apps SET region = ?, region_source = 'chosen' WHERE app = 'titogo'`, appRegion); err != nil {
+ t.Fatal(err)
+ }
+ putEdges(t, fx, map[string]string{"ie-box": homeBox, "uk-box": ukEligible})
+ return fx, token, instancesURL(fx, "bab", "titogo")
+}
+
+func create(t *testing.T, u, token, body string) (int, fillRec, string) {
+ t.Helper()
+ status, _, out := rawReq(t, http.MethodPost, u, token, body)
+ var rec fillRec
+ json.Unmarshal([]byte(out), &rec)
+ return status, rec, out
+}
+
+func TestFillFromTheAppRegion(t *testing.T) {
+ fx, token, u := fillFixture(t, "uk")
+ _ = fx
+ status, rec, out := create(t, u, token, `{"host":"a.go.tito.io"}`)
+ if status != http.StatusCreated || rec.Region != "uk" || rec.RegionFrom != "app" || rec.RegionName != "UK · London" {
+ t.Fatalf("fill = %d %s", status, out)
+ }
+ _, rec, _ = create(t, u, token, `{"host":"b.go.tito.io","region":"ie"}`)
+ if rec.Region != "" || rec.RegionFrom != "request" {
+ t.Fatalf("explicit home = %+v", rec)
+ }
+ for _, body := range []string{
+ `{"host":"c.go.tito.io","kind":"static"}`,
+ `{"host":"d.go.tito.io","kind":"service","addrs":["10.0.0.4:8080"]}`,
+ } {
+ if _, rec, out := create(t, u, token, body); rec.Region != "" {
+ t.Errorf("%s was filled: %s", body, out)
+ }
+ }
+ status, _, out = create(t, u, token, `{"host":"e.go.tito.io","role":"replica"}`)
+ if status != http.StatusBadRequest || !strings.Contains(out, "role replica needs a placement") {
+ t.Errorf("replica without a region = %d %s, want the existing refusal word for word", status, out)
+ }
+}
+
+// App region "" is byte-identical to the pre-change door.
+func TestFillWithAHomeAppRegionChangesNothing(t *testing.T) {
+ _, token, u := fillFixture(t, "")
+ _, rec, _ := create(t, u, token, `{"host":"a.go.tito.io"}`)
+ if rec.Region != "" || rec.RegionFrom != "" {
+ t.Fatalf("home app = %+v", rec)
+ }
+}
+
+// codex R2-2: dormancy keeps explicit requests, never app-derived ones.
+func TestAppDerivedNeedsPositiveEligibility(t *testing.T) {
+ fx, token, u := fillFixture(t, "uk")
+ if err := fx.rel.Delete(t.Context(), "control/edges/uk-box.json"); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.rel.Delete(t.Context(), "control/edges/ie-box.json"); err != nil {
+ t.Fatal(err)
+ }
+ // No placement published at all: explicit uk passes as today...
+ if status, _, out := create(t, u, token, `{"host":"x.go.tito.io","region":"uk"}`); status != http.StatusCreated {
+ t.Fatalf("explicit uk under dormancy = %d %s", status, out)
+ }
+ // ...app-derived uk does not.
+ status, _, out := create(t, u, token, `{"host":"y.go.tito.io"}`)
+ if status != http.StatusBadRequest || !strings.Contains(out, reasonText("absent")) {
+ t.Fatalf("app-derived uk under dormancy = %d %s, want C16 absent", status, out)
+ }
+}
+
+func TestAppDerivedEdgesReadFailureIs503(t *testing.T) {
+ fx, token, u := fillFixture(t, "uk")
+ fx.srv.cfg.Releases = failingList{fx.rel}
+ status, _, out := create(t, u, token, `{"host":"y.go.tito.io"}`)
+ if status != http.StatusServiceUnavailable || !strings.Contains(out, msgAppRegionUnconfirmed("uk")) {
+ t.Fatalf("edges failure = %d %s, want 503 C28", status, out)
+ }
+}
+
+func TestAppDerivedRefusals(t *testing.T) {
+ fx, token, u := fillFixture(t, "uk")
+ m := titoManifest()
+ m.Pool, m.Regions = "", []string{"au"}
+ enableInstances(t, fx, "titogo", m)
+ if status, _, out := create(t, u, token, `{"host":"y.go.tito.io"}`); status != http.StatusBadRequest ||
+ !strings.Contains(out, msgAppRegionNotAllowed("uk", []string{"au"})) {
+ t.Fatalf("allow-list = %d %s, want C17", status, out)
+ }
+ m.Regions = nil
+ enableInstances(t, fx, "titogo", m)
+ if status, _, out := create(t, u, token, `{"host":"z.go.tito.io","replicas":["uk"]}`); status != http.StatusBadRequest ||
+ !strings.Contains(out, msgReplicaOverlap("uk")) {
+ t.Fatalf("replica overlap = %d %s, want C26", status, out)
+ }
+}
+
+// The switch off never breaks a promise already made (§5.6).
+func TestFillRunsWithTheSwitchOff(t *testing.T) {
+ fx, token, u := fillFixture(t, "uk")
+ fx.srv.cfg.AppRegions = false
+ if _, rec, _ := create(t, u, token, `{"host":"a.go.tito.io"}`); rec.Region != "uk" {
+ t.Fatalf("switch off = %+v, want the stored uk", rec)
+ }
+}
+```
+
+`fx.st.db` is the `Store`'s own `*sql.DB` field, reachable because the test
+is in package `console`.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `go test ./internal/console -run 'TestFill|TestAppDerived' -count=1`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement the fill, replacing `apiinstances.go:772`**
+
+```go
+ // The app's region is the default for a create that names none
+ // (nearest-region spec §7, R2). It runs BEFORE every check an explicit
+ // region meets, so a defaulted region meets them all — and it never
+ // runs for a fleet-placed app, a pooled manifest, a replica (which must
+ // name its placement), or anything but a socket-backed instance.
+ raw := strings.TrimSpace(req.Region)
+ regionFrom := ""
+ if raw != "" {
+ regionFrom = "request"
+ }
+ if raw == "" && placed == "" && m.Pool == "" && strings.TrimSpace(req.Role) == "" &&
+ (provision.Record{Kind: strings.TrimSpace(req.Kind), Addrs: req.Addrs}).DerivesPaths() {
+ appRegion, _, err := s.store.AppRegion(acct.ID, app)
+ if err != nil {
+ log.Printf("console: api instances create %s/%s: read app region: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, msgAppRegionUnreadable)
+ return
+ }
+ if appRegion != "" {
+ raw, regionFrom = appRegion, "app"
+ }
+ }
+ if placed != "" {
+ regionFrom = "fleet"
+ }
+ region := s.homeRegionIn(raw)
+ // An APP-DERIVED region needs positive eligibility and never rides the
+ // dormant gate below (codex R2-2): an explicit request keeps today's
+ // gate, an automatic default must be proven.
+ if regionFrom == "app" {
+ files, err := readEdgesFilesStrict(ctx, s.cfg.Releases)
+ if err != nil {
+ log.Printf("console: api instances create %s/%s: read edges: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusServiceUnavailable, msgAppRegionUnconfirmed(s.homeRegionOut(region)))
+ return
+ }
+ cands := regionCandidates(files, s.cfg.HomeRegion)
+ reason := "absent"
+ if c, ok := findCandidate(cands, region); ok {
+ reason = c.unavailableReason()
+ }
+ if reason != "" {
+ var can []string
+ for _, c := range cands {
+ if c.Eligible {
+ can = append(can, s.homeRegionOut(c.Code))
+ }
+ }
+ jsonError(w, http.StatusBadRequest, msgAppRegionUnavailable(s.homeRegionOut(region), reason, can))
+ return
+ }
+ if !instanceRegionAllowed(m.Regions, region) {
+ jsonError(w, http.StatusBadRequest, msgAppRegionNotAllowed(s.homeRegionOut(region), m.Regions))
+ return
+ }
+ for _, rep := range req.Replicas {
+ if s.homeRegionIn(strings.TrimSpace(rep)) == region {
+ jsonError(w, http.StatusBadRequest, msgReplicaOverlap(s.homeRegionOut(region)))
+ return
+ }
+ }
+ }
+```
+
+`req.Replicas` is the embedded `replicaFields.Replicas` (`apiinstances.go:560`).
+
+- [ ] **Step 4: Fill the response fields**
+
+In `apiRecordView`:
+
+```go
+ // RegionFrom and RegionName say how this create chose its region and
+ // what the place is called (nearest-region spec §7) — facts about this
+ // request, not part of the record, like PrivateStoreBucket.
+ RegionFrom string `json:"region_from,omitempty"`
+ RegionName string `json:"region_name,omitempty"`
+```
+
+At `:1066`, after `out := recordView(rec)`:
+
+```go
+ out.RegionFrom = regionFrom
+ if rec.Role == "" && placed == "" {
+ out.RegionName = s.placeOf(rec.Region, regionCandidates(readEdgesFiles(ctx, s.cfg.Releases), s.cfg.HomeRegion))
+ }
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `go test ./internal/console -count=1`
+Expected: PASS, including `TestAPIInstanceCreate*` and the placement-door
+tests.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/console/apiinstances.go internal/console/regionfill_test.go
+git commit -m "console: instance door fills the app region, proves app-derived eligibility (nearest-region §7)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 15: `app_default`, place names and the guess endpoint
+
+**Files:**
+- Modify: `internal/console/apiregions.go`
+- Modify: `internal/console/authcli.go:586` (register the guess route beside `/regions`)
+- Create: `internal/console/regiondefault.go`
+- Test: `internal/console/regiondefault_test.go`
+
+**Interfaces:**
+- Consumes: Tasks 7, 10, 11 and 12.
+- Produces:
+
+```go
+type appDefaultView struct {
+ Region string `json:"region"`
+ Name string `json:"name"`
+ Source string `json:"source"`
+ Effective string `json:"effective"`
+ Available *bool `json:"available,omitempty"`
+ Reason string `json:"reason,omitempty"`
+}
+func (s *Server) appDefault(ctx context.Context, acct *Account, app string) appDefaultView
+func (s *Server) handleAPIRegionGuess(w http.ResponseWriter, r *http.Request) // POST …/regions/guess
+```
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/console/regiondefault_test.go
+package console
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+func regionsURL(fx *consoleFixture) string {
+ return fx.ts.URL + "/api/cli/apps/bab/titogo/regions"
+}
+
+func TestRegionsKeepsLegacyFieldsAndAddsAppDefault(t *testing.T) {
+ fx, token, _ := fillFixture(t, "uk")
+ status, _, out := rawReq(t, http.MethodGet, regionsURL(fx), token, "")
+ var res struct {
+ Regions []struct {
+ Code, AWSRegion, Name string
+ Armed bool
+ } `json:"regions"`
+ Default string `json:"default"`
+ AppDefault appDefaultView `json:"app_default"`
+ }
+ json.Unmarshal([]byte(out), &res)
+ if status != http.StatusOK || res.Default != "ie" {
+ t.Fatalf("legacy default = %d %s", status, out)
+ }
+ if res.AppDefault.Region != "uk" || res.AppDefault.Effective != "uk" || res.AppDefault.Available == nil ||
+ !*res.AppDefault.Available || res.AppDefault.Name != "UK · London" {
+ t.Fatalf("app_default = %+v", res.AppDefault)
+ }
+ if !strings.Contains(out, `"aws_region":"eu-west-2"`) || !strings.Contains(out, `"name":"UK · London"`) {
+ t.Fatalf("entries lack names: %s", out)
+ }
+}
+
+func TestAppDefaultReasons(t *testing.T) {
+ fx, _, _ := fillFixture(t, "uk")
+ acct := &Account{ID: OpsAccountID, Sqid: "bab"}
+ ctx := t.Context()
+ fx.rel.Delete(ctx, "control/edges/uk-box.json")
+ if d := fx.srv.appDefault(ctx, acct, "titogo"); d.Reason != "absent" || d.Available == nil || *d.Available || d.Effective != "uk" {
+ t.Fatalf("absent = %+v (a code no box publishes is still reported)", d)
+ }
+ m := titoManifest()
+ m.Pool, m.Regions = "tito", nil
+ enableInstances(t, fx, "titogo", m)
+ if d := fx.srv.appDefault(ctx, acct, "titogo"); d.Reason != "pooled" || d.Effective != "ie" || d.Region != "uk" {
+ t.Fatalf("pooled = %+v", d)
+ }
+ m.Pool = ""
+ enableInstances(t, fx, "titogo", m)
+ fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme")
+ if d := fx.srv.appDefault(ctx, acct, "titogo"); d.Reason != "fleet" || d.Effective != "fleet/acme" || d.Region != "uk" || d.Available != nil {
+ t.Fatalf("fleet = %+v (codex R2-4)", d)
+ }
+}
+
+func guessURL(fx *consoleFixture) string { return regionsURL(fx) + "/guess" }
+
+func TestRegionGuessEndpoint(t *testing.T) {
+ fx, token, _ := fillFixture(t, "")
+ status, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`)
+ if status != http.StatusOK || !strings.Contains(out, `"code":"uk"`) || !strings.Contains(out, `"source":"timezone"`) {
+ t.Fatalf("guess = %d %s", status, out)
+ }
+ // Review Focus 4: garbage hints are absent, never an error.
+ big := strings.Repeat("a", 10<<10)
+ status, _, out = rawReq(t, http.MethodPost, guessURL(fx), token, `{"ip":"not-an-ip","tz":"`+big+`"}`)
+ if status != http.StatusOK || !strings.Contains(out, `"source":"default"`) {
+ t.Fatalf("garbage = %d %s", status, out)
+ }
+ // Fleet-placed: null (codex R4-1).
+ fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme")
+ if _, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`); !strings.Contains(out, `"guess":null`) {
+ t.Fatalf("fleet guess = %s", out)
+ }
+}
+
+func TestRegionGuessPooledIsNullAndUnreadableIs502(t *testing.T) {
+ fx, token, _ := fillFixture(t, "")
+ m := titoManifest()
+ m.Pool = "tito"
+ enableInstances(t, fx, "titogo", m)
+ if _, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`); !strings.Contains(out, `"guess":null`) {
+ t.Fatalf("pooled guess = %s", out)
+ }
+ fx.rel.Put(t.Context(), provision.ManifestKey("bab", "titogo"), []byte("{not json"), "")
+ status, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`)
+ if status != http.StatusBadGateway || !strings.Contains(out, msgPolicyUnreadable) {
+ t.Fatalf("unreadable = %d %s, want 502 C27", status, out)
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `go test ./internal/console -run 'TestRegionsKeeps|TestAppDefault|TestRegionGuess' -count=1`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement `regiondefault.go`**
+
+```go
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "net"
+ "net/http"
+ "strings"
+)
+
+// appDefaultView is §9.3's app_default: where a create naming no region
+// lands for THIS app, and whether it can land there now. region is the
+// STORED region (what deploy --region compares against); effective is
+// where a create actually lands — the fleet itself for a placed app,
+// never the platform home (codex R2-4).
+type appDefaultView struct {
+ Region string `json:"region"`
+ Name string `json:"name"`
+ Source string `json:"source"`
+ Effective string `json:"effective"`
+ Available *bool `json:"available,omitempty"`
+ Reason string `json:"reason,omitempty"`
+}
+
+func boolp(b bool) *bool { return &b }
+
+func (s *Server) appDefault(ctx context.Context, acct *Account, app string) appDefaultView {
+ stored, source, err := s.store.AppRegion(acct.ID, app)
+ files, ferr := readEdgesFilesStrict(ctx, s.cfg.Releases)
+ cands := homeOnlyCandidates()
+ if ferr == nil {
+ cands = regionCandidates(files, s.cfg.HomeRegion)
+ }
+ v := appDefaultView{Region: s.homeRegionOut(stored), Name: s.placeOf(stored, cands), Source: source}
+ if err != nil {
+ v.Reason = "policy-unreadable"
+ return v
+ }
+ pol := s.appPolicy(ctx, acct, app)
+ switch {
+ case pol.State == policyUnreadable:
+ v.Effective, v.Reason = s.homeRegionOut(stored), "policy-unreadable"
+ return v
+ case pol.Placed != "":
+ v.Effective, v.Reason = pol.Placed, "fleet"
+ return v
+ case pol.pooled():
+ v.Effective, v.Reason, v.Available = s.homeRegionOut(""), "pooled", boolp(true)
+ return v
+ }
+ v.Effective = s.homeRegionOut(stored)
+ if ferr != nil {
+ v.Reason = "edges-unreadable"
+ return v
+ }
+ reason := "absent"
+ if c, ok := findCandidate(cands, stored); ok {
+ reason = c.unavailableReason()
+ }
+ if reason == "" && !pol.allows(stored) {
+ reason = "not-allowed"
+ }
+ v.Reason, v.Available = reason, boolp(reason == "")
+ return v
+}
+
+// handleAPIRegionGuess is POST …/regions/guess (spec §9.4). Hints come
+// only in the body, so no redirect can echo them (codex 8). A person's
+// request with no ip uses its own address; a service principal's never does.
+func (s *Server) handleAPIRegionGuess(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ var body struct {
+ TZ string `json:"tz"`
+ IP string `json:"ip"`
+ }
+ _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body)
+ pol := s.appPolicy(r.Context(), acct, app)
+ if pol.State == policyUnreadable {
+ jsonError(w, http.StatusBadGateway, msgPolicyUnreadable)
+ return
+ }
+ if pol.Placed != "" || pol.pooled() {
+ writeJSON(w, http.StatusOK, map[string]any{"guess": nil})
+ return
+ }
+ ip := net.ParseIP(strings.TrimSpace(body.IP))
+ if ip == nil {
+ ip = s.personIP(r)
+ }
+ files, err := readEdgesFilesStrict(r.Context(), s.cfg.Releases)
+ cands := homeOnlyCandidates()
+ if err == nil {
+ cands = regionCandidates(files, s.cfg.HomeRegion)
+ }
+ g, ok := guessRegion(cands, pol.allows, guessInput{TZ: body.TZ, IP: ip}, s.cfg.GeoIP)
+ if !ok {
+ writeJSON(w, http.StatusOK, map[string]any{"guess": nil})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"guess": map[string]string{
+ "code": s.homeRegionOut(g.Code), "name": s.placeOf(g.Code, cands), "source": g.Source,
+ }})
+}
+```
+
+A decode error (malformed JSON) leaves `body` partly filled, which is the
+"garbage is absent" rule from Review Focus 4.
+
+- [ ] **Step 4: Extend `handleAPIRegions` and register the route**
+
+In `apiregions.go`, give `region` the new fields, and fill them from the
+edges files:
+
+```go
+ type region struct {
+ Code string `json:"code"`
+ Armed bool `json:"armed"`
+ AWSRegion string `json:"aws_region,omitempty"`
+ Name string `json:"name,omitempty"`
+ }
+ set, _ := armedPlacements(r.Context(), s.cfg.Releases)
+ cands := regionCandidates(readEdgesFiles(r.Context(), s.cfg.Releases), s.cfg.HomeRegion)
+ named := func(code string) region {
+ c, _ := findCandidate(cands, s.homeRegionIn(code))
+ return region{Code: code, AWSRegion: c.AWSRegion, Name: s.placeOf(s.homeRegionIn(code), cands)}
+ }
+ out := make([]region, 0, len(set)+1)
+ if s.cfg.HomeRegion != "" {
+ h := named(s.cfg.HomeRegion)
+ h.Armed = true
+ out = append(out, h)
+ delete(set, s.cfg.HomeRegion)
+ }
+ for code, armed := range set {
+ e := named(code)
+ e.Armed = armed
+ out = append(out, e)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code })
+ writeJSON(w, http.StatusOK, map[string]any{
+ "regions": out, "default": s.cfg.HomeRegion,
+ "app_default": s.appDefault(r.Context(), acct, app),
+ })
+```
+
+Change the gate line to `_, acct, app, ok := s.instanceGate(w, r)`. In
+`authcli.go`, beside the `/regions` route:
+
+```go
+ // The per-caller region guess (nearest-region spec §9.4): POST so the
+ // hints never ride a URL a redirect could echo.
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/regions/guess", svcrole.Allow(svcrole.Instance), s.handleAPIRegionGuess)
+```
+
+- [ ] **Step 5: Add the privacy assertion**
+
+Append to `regiondefault_test.go`:
+
+```go
+// codex 8: with a canonical-host redirect configured, a guess POST's hints
+// appear in no Location, no body, no log and no audit row.
+func TestRegionGuessHintsNeverLeakThroughARedirect(t *testing.T) {
+ fx, token, _ := fillFixture(t, "")
+ fx.srv.redirects = map[string]bool{"old.example": true}
+ var logs strings.Builder
+ restore := captureLog(&logs)
+ defer restore()
+ req, _ := http.NewRequest(http.MethodPost, guessURL(fx), strings.NewReader(`{"tz":"Pacific/Chatham","ip":"203.0.113.77"}`))
+ req.Host = "old.example"
+ req.Header.Set("Authorization", "Bearer "+token)
+ res, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := make([]byte, 64<<10)
+ n, _ := res.Body.Read(body)
+ res.Body.Close()
+ everything := res.Header.Get("Location") + string(body[:n]) + logs.String()
+ rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Pacific/Chatham","ip":"203.0.113.77"}`)
+ everything += logs.String()
+ events, err := fx.st.Events(OpsAccountID, "titogo", 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range events {
+ everything += e.Detail + e.Message
+ }
+ for _, secret := range []string{"Chatham", "203.0.113.77"} {
+ if strings.Contains(everything, secret) {
+ t.Fatalf("hint %q leaked: %s", secret, everything)
+ }
+ }
+}
+```
+
+`fx.srv.redirects` is the normalized host set `ServeHTTP` reads
+(`server.go:723`, `:1306`). `Event` carries both `Detail` and `Message`
+(`store.go:2066`).
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+Run: `go test ./internal/console -count=1`
+Expected: PASS, including the existing `/regions` tests. The legacy
+`regions` and `default` fields must be unchanged.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/console/regiondefault.go internal/console/regiondefault_test.go internal/console/apiregions.go internal/console/authcli.go
+git commit -m "console: app_default, place names, POST regions/guess (nearest-region §9.3, §9.4)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 16: The Instances tab lines
+
+**Files:**
+- Modify: `internal/console/server.go` (`page` struct, `handleAppInstances` `:2933-2965`)
+- Modify: `internal/console/templates/app_instances.html:60-78`
+- Test: `internal/console/regiondefault_test.go` (append)
+
+**Interfaces:**
+- Consumes: `s.appDefault` (Task 15) and `regionCopy` (Task 6).
+- Produces: `page.RegionLine string` and `page.RegionLineWarn bool`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestInstancesTabRegionLines(t *testing.T) {
+ fx, _, _ := fillFixture(t, "uk")
+ fx.srv.cfg.AppRegions = true
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/titogo/instances"))
+ if !strings.Contains(body, fmt.Sprintf(regionCopy.StartsIn, "UK · London")) {
+ t.Fatalf("no C7 line")
+ }
+ fx.rel.Delete(t.Context(), "control/edges/uk-box.json")
+ _, _, body = get(t, c, fx.u("/apps/titogo/instances"))
+ // With no box publishing uk there is no AWS region to name it by, so
+ // the place falls back to the bare code (spec §14).
+ if !strings.Contains(body, fmt.Sprintf(regionCopy.CantStart, "uk")) {
+ t.Fatalf("no C8 line when the region is absent (codex 7)")
+ }
+ fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme")
+ _, _, body = get(t, c, fx.u("/apps/titogo/instances"))
+ if strings.Contains(body, "this app's region") {
+ t.Fatal("a fleet-placed app showed a region line")
+ }
+}
+```
+
+Add `"fmt"` to the file's imports. Paul owns the ops account in the
+fixture, so he sees `titogo`, which `instanceFixture` claims there.
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `go test ./internal/console -run TestInstancesTabRegionLines -count=1`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement it**
+
+Add to `page`:
+
+```go
+ // RegionLine is the Instances tab's one-line app-region note (C7 or
+ // C8, nearest-region spec §10); "" shows nothing. RegionLineWarn marks C8.
+ RegionLine string
+ RegionLineWarn bool
+```
+
+In `handleAppInstances`, after the placement section:
+
+```go
+ if d := s.appDefault(r.Context(), acct, p.AppName); d.Reason != "fleet" && d.Reason != "pooled" {
+ files, _ := readEdgesFilesStrict(r.Context(), s.cfg.Releases)
+ switch {
+ case d.Available != nil && !*d.Available:
+ p.RegionLine, p.RegionLineWarn = fmt.Sprintf(regionCopy.CantStart, d.Name), true
+ case d.Region != s.cfg.HomeRegion || eligibleCount(regionCandidates(files, s.cfg.HomeRegion)) >= 2:
+ p.RegionLine = fmt.Sprintf(regionCopy.StartsIn, d.Name)
+ }
+ }
+```
+
+In `app_instances.html`, at the top of the Placement section after
+`<h2>Placement</h2>`:
+
+```html
+{{if .RegionLine}}<p{{if .RegionLineWarn}} class="banner error"{{end}}>{{.RegionLine}}</p>{{end}}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test ./internal/console -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/server.go internal/console/templates/app_instances.html internal/console/regiondefault_test.go
+git commit -m "console: Instances tab says where new instances start, or that they can't (nearest-region §10)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 17: Roll-forward discrepancy log
+
+**Files:**
+- Create: `internal/console/regiondrift.go`
+- Modify: `cmd/console/main.go` (call once after `NewServer`)
+- Test: `internal/console/regiondrift_test.go`
+
+**Interfaces:**
+- Consumes: `Store.NonHomeAppRegions` (Task 8), `provision.ListRecords`, and
+ `msgPlacementDiscrepancy` (Task 7).
+- Produces: `func (s *Server) LogPlacementDiscrepancies(ctx context.Context)`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+// internal/console/regiondrift_test.go
+package console
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// codex R2-6: shapes that never inherit the app default are not listed;
+// a no-region socket-backed instance created after the app is.
+func TestPlacementDiscrepancyLog(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.HomeRegion = "ie"
+ created := time.Now().Add(-time.Hour)
+ fx.st.CreateAppInRegion(OpsAccountID, "blog", "p@k.dev", "uk", "chosen", created)
+ ctx := context.Background()
+ put := func(r provision.Record) {
+ r.Schema, r.App, r.Account, r.Channel, r.CreatedAt = 1, "blog", release.OpsAccount, "stable", time.Now()
+ if err := provision.CreateRecord(ctx, fx.rel, r); err != nil {
+ t.Fatal(err)
+ }
+ }
+ put(provision.Record{Host: "blog.go.tito.io", Kind: "static"})
+ put(provision.Record{Host: "svc.go.tito.io", Kind: "service", Addrs: []string{"10.0.0.4:8080"}})
+ put(provision.Record{Host: "r.go.tito.io", Region: "au", Role: "replica"})
+ put(provision.Record{Host: "pooled.go.tito.io", Pool: "tito"})
+ put(provision.Record{Host: "uk.go.tito.io", Region: "uk"})
+ put(provision.Record{Host: "drift.go.tito.io"})
+ var logs strings.Builder
+ restore := captureLog(&logs)
+ fx.srv.LogPlacementDiscrepancies(ctx)
+ restore()
+ out := logs.String()
+ if !strings.Contains(out, msgPlacementDiscrepancy("drift.go.tito.io", "blog", "uk")) {
+ t.Fatalf("drift not listed: %s", out)
+ }
+ for _, quiet := range []string{"blog.go.tito.io", "svc.go.tito.io", "r.go.tito.io", "pooled.go.tito.io", "uk.go.tito.io"} {
+ if strings.Contains(out, quiet+" of") {
+ t.Errorf("%s listed", quiet)
+ }
+ }
+}
+```
+
+`provision.CreateRecord` is a plain create-only write (`provision.go:820`),
+so these fixture records need no manifest.
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `go test ./internal/console -run TestPlacementDiscrepancyLog -count=1`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement `regiondrift.go`**
+
+```go
+package console
+
+import (
+ "context"
+ "log"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// discrepancyScanCap bounds one app's record listing. ListRecords treats
+// max=0 as "list nothing", so the cap must be explicit.
+const discrepancyScanCap = 10000
+
+// LogPlacementDiscrepancies is spec §5.7 step 4, run once at console
+// start. For each app holding a non-home region, it lists records with an
+// empty region created after the app — the shape a console rolled back
+// below the floor would have written. Shapes that never inherit the app
+// default are skipped (static, addr-backed, replica, pooled, fleet). A
+// deliberate home override looks the same and the record keeps no
+// provenance, so each line is a CANDIDATE for review, worded as one (C29).
+func (s *Server) LogPlacementDiscrepancies(ctx context.Context) {
+ if s.cfg.Releases == nil {
+ return
+ }
+ apps, err := s.store.NonHomeAppRegions()
+ if err != nil {
+ log.Printf("console: placement discrepancy check: %v", err)
+ return
+ }
+ for _, a := range apps {
+ recs, truncated, err := provision.ListRecords(ctx, s.cfg.Releases, a.Sqid, a.App, discrepancyScanCap)
+ if err != nil {
+ log.Printf("console: placement discrepancy check %s/%s: %v", a.Sqid, a.App, err)
+ continue
+ }
+ if truncated {
+ log.Printf("console: placement discrepancy check %s/%s: only the first %d records were checked", a.Sqid, a.App, discrepancyScanCap)
+ }
+ for _, r := range recs {
+ if r.Region != "" || r.Role != "" || r.Pool != "" || !r.DerivesPaths() || r.CreatedAt.Before(a.CreatedAt) {
+ continue
+ }
+ log.Print("console: " + msgPlacementDiscrepancy(r.Host, a.App, a.Region))
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Call it at startup**
+
+In `cmd/console/main.go`, after `srv` is built and before serving:
+
+```go
+ // Spec §5.7: after any roll, list possible placement discrepancies for
+ // operator review. Read-only; never blocks startup.
+ go srv.LogPlacementDiscrepancies(context.Background())
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `go test ./internal/console ./cmd/console -count=1`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/console/regiondrift.go internal/console/regiondrift_test.go cmd/console/main.go
+git commit -m "console: list possible placement discrepancies on start (nearest-region §5.7)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 18: CLI copy (C9 to C15, C13a, C13b, C15b, C22, C25, C30; copy-review gate)
+
+**Files:**
+- Create: `region_copy.go` (root, package `main`)
+- Test: `region_copy_test.go`
+
+**Interfaces:**
+- Produces:
+
+```go
+func regionCreatedLine(place, source string) string // C9 to C12
+const regionNotRecordedOldConsole = "…" // C13a
+func regionNotAppliedSwitchOff(place string) string // C13b
+func instanceCreatedSuffix(channel, place string) string // C14: "channel X, PLACE"
+func deployRegionMismatch(app, place string) string // C15
+func deployRegionUnchecked(app string, err error) string // C15b
+func canaryRegionMismatch(host, place string) string // C25
+func canaryRegionOnFleet(app, fleet string) string // C30
+const flagRegionAppsCreate, flagRegionDeploy, flagRegionCanary, flagRegionInstancesCreate = "…", "…", "…", "…" // C22
+```
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+// region_copy_test.go
+package main
+
+import (
+ "errors"
+ "strings"
+ "testing"
+)
+
+func TestRegionCLICopy(t *testing.T) {
+ want := map[string]string{
+ "timezone": "Region: UK · London (from your timezone). Use --region to choose another.",
+ "ip": "Region: UK · London (from your network location). Use --region to choose another.",
+ "default": "Region: UK · London (the platform default). Use --region to choose another.",
+ "chosen": "Region: UK · London.",
+ }
+ for src, w := range want {
+ if got := regionCreatedLine("UK · London", src); got != w {
+ t.Errorf("%s: %q, want %q", src, got, w)
+ }
+ }
+ all := []string{
+ regionNotRecordedOldConsole, regionNotAppliedSwitchOff("Ireland · Dublin"),
+ instanceCreatedSuffix("stable", "UK · London"), deployRegionMismatch("blog", "UK · London"),
+ deployRegionUnchecked("blog", errors.New("timeout")),
+ canaryRegionMismatch("c.go.tito.io", "UK · London"), canaryRegionOnFleet("blog", "acme"),
+ flagRegionAppsCreate, flagRegionDeploy, flagRegionCanary, flagRegionInstancesCreate,
+ }
+ for _, s := range all {
+ if s == "" || strings.Contains(s, "—") || strings.Contains(s, "%!") {
+ t.Errorf("bad copy %q", s)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `go test . -run TestRegionCLICopy -count=1`
+Expected: FAIL.
+
+- [ ] **Step 3: Write the drafts, verbatim from spec §19**
+
+```go
+package main
+
+import "fmt"
+
+// The nearest-region CLI strings (spec §19). DRAFTS pending Paul's copy
+// review: change none of them without it.
+
+// C9 to C12, by source word.
+func regionCreatedLine(place, source string) string {
+ switch source {
+ case "timezone":
+ return fmt.Sprintf("Region: %s (from your timezone). Use --region to choose another.", place)
+ case "ip":
+ return fmt.Sprintf("Region: %s (from your network location). Use --region to choose another.", place)
+ case "chosen":
+ return fmt.Sprintf("Region: %s.", place)
+ }
+ return fmt.Sprintf("Region: %s (the platform default). Use --region to choose another.", place)
+}
+
+// C13a
+const regionNotRecordedOldConsole = "This console does not record an app region yet, so --region was not applied."
+
+// C13b
+func regionNotAppliedSwitchOff(place string) string {
+ return fmt.Sprintf("This console is not assigning app regions yet, so --region was not applied. This app's instances start in %s.", place)
+}
+
+// C14: the parenthesis content of "created instance {host} (...)".
+func instanceCreatedSuffix(channel, place string) string {
+ return fmt.Sprintf("channel %s, %s", channel, place)
+}
+
+// C15
+func deployRegionMismatch(app, place string) string {
+ 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)
+}
+
+// C15b: not in spec §19; added by this plan for the case where the
+// console cannot answer. Muted operator note, deploy continues.
+func deployRegionUnchecked(app string, err error) string {
+ return fmt.Sprintf("could not read %s's region (%v), so --region was not checked.", app, err)
+}
+
+// C25
+func canaryRegionMismatch(host, place string) string {
+ 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)
+}
+
+// C30
+func canaryRegionOnFleet(app, fleet string) string {
+ 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)
+}
+
+// C22: flag help.
+const (
+ flagRegionAppsCreate = "region this app's instances start in (default: the region nearest you)"
+ flagRegionDeploy = "region for the app this deploy creates (default: the region nearest you)"
+ flagRegionCanary = "region for the canary instance (default: the app's region)"
+ flagRegionInstancesCreate = "region for this instance (default: the app's region, subject to the manifest's allow-list)"
+)
+```
+
+- [ ] **Step 4: Run it to verify it passes**
+
+Run: `go test . -run TestRegionCLICopy -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add region_copy.go region_copy_test.go
+git commit -m "cli: region copy (drafts C9-C15, C22, C25, C30, pending review)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+- [ ] **Step 6: Copy-review gate**
+
+Invoke `copy-review` with every string above, each with its id and where it
+prints. Flag C15b as new (not drafted in the spec). Apply Paul's edits verbatim, update the test's `want` map, re-run
+step 4, and commit as `cli: region copy as approved`. **Do not land the
+branch before this closes.**
+
+---
+
+### Task 19: Timezone detection
+
+**Files:**
+- Create: `timezone.go` (root)
+- Test: `timezone_test.go`
+
+**Interfaces:**
+- Produces:
+ - `func localTimezone(getenv func(string) string, readlink func(string) (string, error), readFile func(string) ([]byte, error)) string`;
+ - `func detectTimezone() string`, which wraps `localTimezone` with the `os`
+ functions.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// timezone_test.go
+package main
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestLocalTimezone(t *testing.T) {
+ noLink := func(string) (string, error) { return "", errors.New("no") }
+ noFile := func(string) ([]byte, error) { return nil, errors.New("no") }
+ env := func(v string) func(string) string {
+ return func(k string) string {
+ if k == "TZ" {
+ return v
+ }
+ return ""
+ }
+ }
+ cases := []struct {
+ name string
+ getenv func(string) string
+ readlink func(string) (string, error)
+ readFile func(string) ([]byte, error)
+ want string
+ }{
+ {"TZ plain", env("Europe/London"), noLink, noFile, "Europe/London"},
+ {"TZ colon", env(":Europe/London"), noLink, noFile, "Europe/London"},
+ {"TZ path", env("/usr/share/zoneinfo/Asia/Kolkata"), noLink, noFile, "Asia/Kolkata"},
+ {"localtime link (linux)", env(""), func(string) (string, error) { return "/usr/share/zoneinfo/Europe/Dublin", nil }, noFile, "Europe/Dublin"},
+ {"localtime link (macOS)", env(""), func(string) (string, error) { return "/var/db/timezone/zoneinfo/Australia/Sydney", nil }, noFile, "Australia/Sydney"},
+ {"etc/timezone", env(""), noLink, func(string) ([]byte, error) { return []byte("Europe/Paris\n"), nil }, "Europe/Paris"},
+ // Review Focus 1: real-machine noise is a clean name or nothing.
+ {"etc/timezone padded", env(""), noLink, func(string) ([]byte, error) { return []byte(" America/New_York \r\n# trailing\n"), nil }, "America/New_York"},
+ {"TZ bare colon", env(":"), noLink, noFile, ""},
+ {"TZ localtime", env("localtime"), noLink, noFile, ""},
+ {"TZ posixrules path", env("/usr/share/zoneinfo/posixrules"), noLink, noFile, ""},
+ {"TZ POSIX rule string", env("EST5EDT,M3.2.0,M11.1.0"), noLink, noFile, ""},
+ {"nothing", env(""), noLink, noFile, ""},
+ }
+ for _, c := range cases {
+ if got := localTimezone(c.getenv, c.readlink, c.readFile); got != c.want {
+ t.Errorf("%s: %q, want %q", c.name, got, c.want)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `go test . -run TestLocalTimezone -count=1`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement `timezone.go`**
+
+```go
+package main
+
+import (
+ "os"
+ "regexp"
+ "strings"
+)
+
+// tzName is the shape of an IANA zone name the console will accept (spec
+// §6.2 step 1). Anything else is not sent: the console then falls back to
+// the request's IP, then the home.
+var tzName = regexp.MustCompile(`^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+){0,2}$`)
+
+var notAZone = map[string]bool{"localtime": true, "posixrules": true, "Local": true}
+
+// localTimezone finds this machine's IANA zone name, portably (no build
+// tags; the 20-cross gate builds it for darwin, windows and linux-arm64):
+// $TZ, else the /etc/localtime symlink target, else /etc/timezone, else "".
+// Windows has none of these, so only a valid $TZ is ever sent there.
+func localTimezone(getenv func(string) string, readlink func(string) (string, error), readFile func(string) ([]byte, error)) string {
+ if tz := clean(strings.TrimPrefix(strings.TrimSpace(getenv("TZ")), ":")); tz != "" {
+ return tz
+ }
+ if target, err := readlink("/etc/localtime"); err == nil {
+ if tz := clean(target); tz != "" {
+ return tz
+ }
+ }
+ if b, err := readFile("/etc/timezone"); err == nil {
+ line, _, _ := strings.Cut(string(b), "\n")
+ return clean(line)
+ }
+ return ""
+}
+
+func clean(s string) string {
+ s = strings.TrimSpace(s)
+ if _, after, ok := strings.Cut(s, "zoneinfo/"); ok {
+ s = after
+ }
+ if s == "" || strings.HasPrefix(s, "/") || notAZone[s] || len(s) > 64 || !tzName.MatchString(s) {
+ return ""
+ }
+ return s
+}
+
+func detectTimezone() string { return localTimezone(os.Getenv, os.Readlink, os.ReadFile) }
+```
+
+- [ ] **Step 4: Run them to verify they pass, and cross-compile**
+
+Run: `go test . -run TestLocalTimezone -count=1 && GOOS=windows go build . && GOOS=darwin go build .`
+Expected: PASS, and both builds succeed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add timezone.go timezone_test.go
+git commit -m "cli: portable timezone detection (nearest-region §11)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 20: Create-app wire, `apps create --region` and the onboarding line
+
+**Files:**
+- Modify: `apimode.go:958-1015` (`apiCreateAppResult`, `createApp`)
+- Modify: `apps.go:442-510` (`runAppsCreate`)
+- Modify: `deploy.go:513-541` (`offerCreateApp`), `deploy.go:36-60` (`runDeploy` flags)
+- Modify: `apps_test.go:299-321` and every other `createApp(` call site (`grep -n 'createApp(' *.go`)
+- Test: `apps_region_test.go`
+
+**Interfaces:**
+- Consumes: Tasks 18 and 19.
+- Produces:
+ - `func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string, controlPlane bool, buildProbe, region, timezone string) (apiCreateAppResult, error)`;
+ - new result fields `Region`, `RegionName`, `RegionSource string` and
+ `RegionsAvailable int`;
+ - `func printAppRegion(w io.Writer, res apiCreateAppResult, asked string)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// apps_region_test.go
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestCreateAppSendsRegionAndTimezone(t *testing.T) {
+ var got map[string]any
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewDecoder(r.Body).Decode(&got)
+ json.NewEncoder(w).Encode(map[string]any{"account": "a", "app": "blog",
+ "region": "uk", "region_name": "UK · London", "region_source": "timezone", "regions_available": 3})
+ }))
+ defer ts.Close()
+ res, err := newTestSession(ts.URL).createApp(context.Background(), apiAcct{Seg: "a"}, "blog", "", false, "", "", "Europe/London")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got["timezone"] != "Europe/London" {
+ t.Fatalf("body = %v", got)
+ }
+ if _, sent := got["region"]; sent {
+ t.Fatal("region sent without --region")
+ }
+ if res.RegionName != "UK · London" || res.RegionsAvailable != 3 {
+ t.Fatalf("result = %+v", res)
+ }
+}
+
+func TestPrintAppRegion(t *testing.T) {
+ cases := []struct {
+ res apiCreateAppResult
+ asked string
+ want string
+ }{
+ {apiCreateAppResult{Region: "uk", RegionName: "UK · London", RegionSource: "timezone", RegionsAvailable: 2}, "", regionCreatedLine("UK · London", "timezone") + "\n"},
+ {apiCreateAppResult{Region: "ie", RegionName: "Ireland · Dublin", RegionSource: "default", RegionsAvailable: 1}, "", ""},
+ {apiCreateAppResult{}, "uk", regionNotRecordedOldConsole + "\n"},
+ {apiCreateAppResult{Region: "ie", RegionName: "Ireland · Dublin", RegionSource: "default", RegionsAvailable: 2}, "uk", regionNotAppliedSwitchOff("Ireland · Dublin") + "\n"},
+ {apiCreateAppResult{}, "", ""},
+ }
+ for i, c := range cases {
+ var b bytes.Buffer
+ printAppRegion(&b, c.res, c.asked)
+ if b.String() != c.want {
+ t.Errorf("case %d: %q, want %q", i, b.String(), c.want)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `go test . -run 'TestCreateAppSendsRegionAndTimezone|TestPrintAppRegion' -count=1`
+Expected: FAIL to compile.
+
+- [ ] **Step 3: Extend the wire**
+
+In `apiCreateAppResult`:
+
+```go
+ // The app region the console STORED (nearest-region spec §9.1), under
+ // its name. Absent from an older console, which the CLI reads as "no
+ // app region recorded".
+ Region string `json:"region"`
+ RegionName string `json:"region_name"`
+ RegionSource string `json:"region_source"`
+ RegionsAvailable int `json:"regions_available"`
+```
+
+Replace `createApp`'s signature and body struct:
+
+```go
+func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string, controlPlane bool, buildProbe, region, timezone string) (apiCreateAppResult, error) {
+ in := struct {
+ Name string `json:"name"`
+ Place string `json:"place,omitempty"`
+ ControlPlane bool `json:"control_plane,omitempty"`
+ BuildProbe string `json:"build_probe,omitempty"`
+ Region string `json:"region,omitempty"`
+ Timezone string `json:"timezone,omitempty"`
+ }{app, place, controlPlane, buildProbe, region, timezone}
+```
+
+Leave the rest of the function unchanged. Update every other call site with
+`, "", ""` (tests) or the real values (steps 4 and 5).
+
+- [ ] **Step 4: Add `printAppRegion` and wire `apps create`**
+
+In `region_copy.go`'s neighbour, a new file `apps_region.go`:
+
+```go
+package main
+
+import (
+ "fmt"
+ "io"
+)
+
+// printAppRegion is spec §11's one line after "created app …": nothing on
+// a single-region deployment, C13a when an older console ignored --region,
+// C13b when a console with the switch off did, else C9 to C12 by source.
+func printAppRegion(w io.Writer, res apiCreateAppResult, asked string) {
+ switch {
+ case res.Region == "" && res.RegionSource == "":
+ if asked != "" {
+ fmt.Fprintln(w, regionNotRecordedOldConsole)
+ }
+ case asked != "" && res.Region != asked:
+ fmt.Fprintln(w, regionNotAppliedSwitchOff(res.RegionName))
+ case res.RegionsAvailable >= 2:
+ fmt.Fprintln(w, regionCreatedLine(res.RegionName, res.RegionSource))
+ }
+}
+```
+
+In `runAppsCreate`, register the flag and pass it on:
+
+```go
+ region := fs.String("region", "", flagRegionAppsCreate)
+```
+
+```go
+ res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app), place2, *controlPlane, probe2,
+ strings.TrimSpace(*region), detectTimezone())
+```
+
+After the existing "created app" line at `apps.go:502`:
+
+```go
+ printAppRegion(stdout, res, strings.TrimSpace(*region))
+```
+
+Add `[--region <code>]` to the usage string at `apps.go:463`.
+
+- [ ] **Step 5: Wire `deploy`'s onboarding**
+
+In `runDeploy`'s flags:
+
+```go
+ regionFlag := fs.String("region", "", flagRegionDeploy)
+```
+
+Pass it to `offerCreateApp(ctx, sess, acct, na, strings.TrimSpace(*regionFlag))`.
+Change that function's signature to take `region string`, and its create call
+and output to:
+
+```go
+ res, err := sess.createApp(ctx, acct, name, "", false, "", region, detectTimezone())
+ if err != nil {
+ log.Fatalf("deploy: create app: %v", err)
+ }
+ fmt.Fprintf(stdout, "%s %s in account %s\n", colorFor(stdout).ok("created app"), res.App, res.Account)
+ printAppRegion(stdout, res, region)
+ return res.App
+```
+
+Add `[--region <code>]` to `runDeploy`'s usage string.
+
+- [ ] **Step 6: Run the root package tests**
+
+Run: `go test . -count=1 -timeout 30m`
+Expected: PASS. The e2e tests shell out through `carlosTestEnv`, so nothing
+reaches a real console.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add apimode.go apps.go apps_region.go apps_region_test.go deploy.go apps_test.go
+git commit -m "cli: apps create and deploy onboarding send a timezone, take --region, print the region line (nearest-region §11)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 21: `deploy --region` on an existing app
+
+**Files:**
+- Modify: `apimode.go` (add `appRegions`)
+- Modify: `deploy.go` (`runDeploy`, after the app is resolved and before anything ships)
+- Test: `deploy_region_test.go`
+
+**Interfaces:**
+- Consumes: `deployRegionMismatch` (Task 18).
+- Produces:
+
+```go
+type apiAppDefault struct {
+ Region, Name, Source, Effective, Reason string
+ Available *bool
+}
+type apiRegionsResult struct {
+ Default string `json:"default"`
+ AppDefault *apiAppDefault `json:"app_default"`
+}
+func (s *apiSession) appRegions(ctx context.Context, a apiAcct, app string) (apiRegionsResult, error)
+func deployRegionPreflight(res apiRegionsResult, app, asked string) error // nil = proceed
+```
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+// deploy_region_test.go
+package main
+
+import "testing"
+
+func TestDeployRegionPreflight(t *testing.T) {
+ uk := &apiAppDefault{Region: "uk", Name: "UK · London", Effective: "uk"}
+ fleet := &apiAppDefault{Region: "uk", Name: "UK · London", Effective: "fleet/acme", Reason: "fleet"}
+ cases := []struct {
+ res apiRegionsResult
+ asked string
+ wantErr bool
+ }{
+ {apiRegionsResult{AppDefault: uk}, "", false},
+ {apiRegionsResult{AppDefault: uk}, "uk", false},
+ {apiRegionsResult{AppDefault: uk}, "ie", true},
+ {apiRegionsResult{AppDefault: nil}, "ie", false}, // an old console: skipped
+ // codex R2-4: a fleet-placed app is judged by its STORED region.
+ {apiRegionsResult{AppDefault: fleet}, "uk", false},
+ {apiRegionsResult{AppDefault: fleet}, "ie", true},
+ }
+ for i, c := range cases {
+ err := deployRegionPreflight(c.res, "blog", c.asked)
+ if (err != nil) != c.wantErr {
+ t.Errorf("case %d: err = %v, wantErr %v", i, err, c.wantErr)
+ }
+ if err != nil && err.Error() != deployRegionMismatch("blog", c.res.AppDefault.Name) {
+ t.Errorf("case %d: message %q", i, err)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `go test . -run TestDeployRegionPreflight -count=1`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement it**
+
+In `apimode.go`:
+
+```go
+// apiAppDefault and apiRegionsResult are the CLI's view of GET
+// …/regions (nearest-region spec §9.3). AppDefault is nil from a console
+// that predates it.
+type apiAppDefault struct {
+ Region string `json:"region"`
+ Name string `json:"name"`
+ Source string `json:"source"`
+ Effective string `json:"effective"`
+ Reason string `json:"reason"`
+ Available *bool `json:"available"`
+}
+
+type apiRegionsResult struct {
+ Default string `json:"default"`
+ AppDefault *apiAppDefault `json:"app_default"`
+}
+
+func (s *apiSession) appRegions(ctx context.Context, a apiAcct, app string) (apiRegionsResult, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/regions"))
+ if err != nil {
+ return apiRegionsResult{}, err
+ }
+ if status != http.StatusOK {
+ return apiRegionsResult{}, apiErrorFromBody(status, body)
+ }
+ var out apiRegionsResult
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiRegionsResult{}, fmt.Errorf("regions: unreadable response: %w", err)
+ }
+ return out, nil
+}
+```
+
+In `deploy.go`:
+
+```go
+// deployRegionPreflight is spec §11: --region on an EXISTING app compares
+// against the app's STORED region — silent if equal, refused before
+// anything ships if not. Skipped against a console with no app_default.
+func deployRegionPreflight(res apiRegionsResult, app, asked string) error {
+ if asked == "" || res.AppDefault == nil || res.AppDefault.Region == asked {
+ return nil
+ }
+ return errors.New(deployRegionMismatch(app, res.AppDefault.Name))
+}
+```
+
+In `runDeploy`, declare `createdNow := false` just before the
+`if *app == "" {` block, and set it in the onboarding case:
+
+```go
+ case errors.As(aerr, &na) && interactiveTerminal():
+ *app = offerCreateApp(ctx, sess, acct, na, strings.TrimSpace(*regionFlag))
+ newly["app"] = *app
+ createdNow = true
+```
+
+Then, straight after that block closes and before `readCapabilities` and any
+ship:
+
+```go
+ if r := strings.TrimSpace(*regionFlag); r != "" && !createdNow {
+ res, err := sess.appRegions(ctx, acct, *app)
+ if err != nil {
+ mutedf(stdout, "%s\n", deployRegionUnchecked(*app, err))
+ } else if err := deployRegionPreflight(res, *app, r); err != nil {
+ log.Fatalf("deploy: %v", err)
+ }
+ }
+```
+
+- [ ] **Step 4: Run it to verify it passes**
+
+Run: `go test . -run 'TestDeployRegionPreflight|TestDeploy' -count=1 -timeout 30m`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apimode.go deploy.go deploy_region_test.go
+git commit -m "cli: deploy --region on an existing app compares the stored region before shipping (nearest-region §11)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 22: `canary --region` and the instance output
+
+**Files:**
+- Modify: `canary.go:338-470` (`runCanaryDeploy`)
+- Modify: `instances_cli.go:288-330` (flag help, output)
+- Modify: `apimode.go` (`apiInstanceRecord`: add `RegionName`, `RegionFrom`)
+- Test: `canary_region_test.go`
+
+**Interfaces:**
+- Consumes: Tasks 18 and 21 (`appRegions`, `canaryRegionOnFleet`,
+ `canaryRegionMismatch`, `instanceCreatedSuffix`).
+- Produces:
+ - `func canaryRegionCheck(res apiRegionsResult, recs []apiInstanceRecord, app, host, asked string) error`;
+ - `func instanceCreatedLine(rec apiInstanceRecord, envSuffix string) string`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// canary_region_test.go
+package main
+
+import "testing"
+
+func TestCanaryRegionCheck(t *testing.T) {
+ plain := apiRegionsResult{Default: "ie", AppDefault: &apiAppDefault{Region: "uk", Effective: "uk", Name: "UK · London"}}
+ fleet := apiRegionsResult{Default: "ie", AppDefault: &apiAppDefault{Region: "uk", Effective: "fleet/acme", Reason: "fleet"}}
+ existingUK := []apiInstanceRecord{{Host: "c.go.tito.io", Region: "uk"}}
+ existingHome := []apiInstanceRecord{{Host: "c.go.tito.io", Region: ""}}
+ cases := []struct {
+ name string
+ res apiRegionsResult
+ recs []apiInstanceRecord
+ asked string
+ want string
+ }{
+ {"no flag", fleet, existingUK, "", ""},
+ {"new canary", plain, nil, "uk", ""},
+ {"existing, same", plain, existingUK, "uk", ""},
+ {"existing home read as its name", plain, existingHome, "ie", ""},
+ {"existing, different", plain, existingUK, "ie", canaryRegionMismatch("c.go.tito.io", "uk")},
+ // codex R4-2: refused identically before and after the canary exists.
+ {"fleet, first run", fleet, nil, "uk", canaryRegionOnFleet("blog", "acme")},
+ {"fleet, repeat", fleet, existingUK, "uk", canaryRegionOnFleet("blog", "acme")},
+ }
+ for _, c := range cases {
+ err := canaryRegionCheck(c.res, c.recs, "blog", "c.go.tito.io", c.asked)
+ got := ""
+ if err != nil {
+ got = err.Error()
+ }
+ if got != c.want {
+ t.Errorf("%s: %q, want %q", c.name, got, c.want)
+ }
+ }
+}
+
+func TestInstanceCreatedLine(t *testing.T) {
+ r := apiInstanceRecord{Host: "a.go.tito.io", Channel: "stable", RegionName: "UK · London"}
+ if got := instanceCreatedLine(r, ""); got != "a.go.tito.io ("+instanceCreatedSuffix("stable", "UK · London")+")" {
+ t.Fatalf("got %q", got)
+ }
+ env := environmentSuffix("acme")
+ if got := instanceCreatedLine(r, env); got != "a.go.tito.io ("+instanceCreatedSuffix("stable", "UK · London")+env+")" {
+ t.Fatalf("with environment = %q", got)
+ }
+ r.RegionName = ""
+ if got := instanceCreatedLine(r, env); got != "a.go.tito.io (channel stable"+env+")" {
+ t.Fatalf("an old console's record = %q, want today's line byte for byte", got)
+ }
+}
+```
+
+`apiInstanceRecord`'s host and channel field names must match the struct at
+`apimode.go:2840-2870`. If they differ, use the real names.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `go test . -run 'TestCanaryRegionCheck|TestInstanceCreatedLine' -count=1`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement the helpers**
+
+Add to `apiInstanceRecord`:
+
+```go
+ RegionName string `json:"region_name,omitempty"`
+ RegionFrom string `json:"region_from,omitempty"`
+```
+
+In `canary.go`:
+
+```go
+// canaryRegionCheck is spec §11's canary --region rule, resolved BEFORE
+// the create/rechannel/ready branch so the answer never depends on whether
+// the canary exists yet (codex R4-2): a fleet-placed app refuses the flag
+// every time (C30); an existing canary in another region refuses (C25);
+// otherwise proceed.
+func canaryRegionCheck(res apiRegionsResult, recs []apiInstanceRecord, app, host, asked string) error {
+ if asked == "" {
+ return nil
+ }
+ if d := res.AppDefault; d != nil && strings.HasPrefix(d.Effective, "fleet/") {
+ return errors.New(canaryRegionOnFleet(app, strings.TrimPrefix(d.Effective, "fleet/")))
+ }
+ for _, r := range recs {
+ if r.Host != host {
+ continue
+ }
+ have := r.Region
+ if have == "" {
+ have = res.Default
+ }
+ if have != asked {
+ return errors.New(canaryRegionMismatch(host, have))
+ }
+ }
+ return nil
+}
+```
+
+In `instances_cli.go`:
+
+```go
+// instanceCreatedLine is the text after "created instance" or "canary
+// host": today's "host (channel X<env>)" byte for byte from an older
+// console, C14 when the console names the place. envSuffix is
+// environmentSuffix(rec.Environment), which has always rendered inside the
+// parenthesis.
+func instanceCreatedLine(rec apiInstanceRecord, envSuffix string) string {
+ if rec.RegionName == "" {
+ return fmt.Sprintf("%s (channel %s%s)", rec.Host, rec.Channel, envSuffix)
+ }
+ return fmt.Sprintf("%s (%s%s)", rec.Host, instanceCreatedSuffix(rec.Channel, rec.RegionName), envSuffix)
+}
+```
+
+- [ ] **Step 4: Wire the flags and the output**
+
+In `runCanaryDeploy`, register the flag:
+
+```go
+ regionFlag := fs.String("region", "", flagRegionCanary)
+```
+
+After `recs` is listed and before `canaryRecordPlan`:
+
+```go
+ if asked := strings.TrimSpace(*regionFlag); asked != "" {
+ res, err := sess.appRegions(ctx, acct, app2)
+ if err != nil {
+ log.Fatalf("canary: read %s's region: %v", app2, err)
+ }
+ if err := canaryRegionCheck(res, recs, app2, host, asked); err != nil {
+ log.Fatalf("canary: %v", err)
+ }
+ }
+```
+
+In the create branch, pass the flag:
+`sess.instanceCreate(ctx, acct, app2, host, strings.TrimSpace(*regionFlag), channel, recKind, env, nil, nil, nil)`.
+Print with the new helper:
+
+```go
+ fmt.Fprintf(stdout, "%s %s\n", pal.ok("canary host"), instanceCreatedLine(rec, environmentSuffix(rec.Environment)))
+```
+
+In `runInstancesCreate`, change the flag help to `flagRegionInstancesCreate`,
+and the output line to:
+
+```go
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("created instance"),
+ instanceCreatedLine(rec, environmentSuffix(rec.Environment)))
+```
+
+- [ ] **Step 5: Run the root tests**
+
+Run: `go test . -count=1 -timeout 30m`
+Expected: PASS, including `canary_test.go` and
+`instances_environment_test.go` byte-for-byte output assertions against a
+console that sends no `region_name`.
+
+- [ ] **Step 6: Cross-compile, then commit**
+
+Run: `GOOS=darwin go build . && GOOS=windows go build . && GOOS=linux GOARCH=arm64 go build ./...`
+
+```bash
+git add canary.go canary_region_test.go instances_cli.go apimode.go
+git commit -m "cli: canary --region with fleet precedence, place in instance output (nearest-region §11, codex R4-2)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 23: The region service-gate script
+
+**Files:**
+- Create: `scripts/region-service-gate.sh`
+- Test: `region_service_gate_test.go` (root package `main`, which runs with
+ the repo root as its working directory; a test-only package under
+ `scripts/` would be a directory with no non-test Go files)
+
+**Interfaces:**
+- Produces: `scripts/region-service-gate.sh <probe-host> <expected-version> [<edge-eip> ...]`.
+ It exits 0 only when every request returns 2xx with
+ `X-Carlos-Version == <expected-version>`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+// region_service_gate_test.go
+package main
+
+import (
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+// The gate is spec §17 step 5 (codex R4-3): it must fail on a 404 (the
+// edge's unknown-host answer when backhaul has no mesh key) and on a
+// version mismatch, and pass only when every path serves the build.
+func TestRegionServiceGate(t *testing.T) {
+ version := "abc1234"
+ ok := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Carlos-Version", version)
+ }))
+ defer ok.Close()
+ unknown := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ }))
+ defer unknown.Close()
+ addr := func(s *httptest.Server) string { h, _, _ := net.SplitHostPort(s.Listener.Addr().String()); return h }
+ port := func(s *httptest.Server) string { _, p, _ := net.SplitHostPort(s.Listener.Addr().String()); return p }
+ run := func(srv *httptest.Server, want string) (string, error) {
+ cmd := exec.Command("./scripts/region-service-gate.sh", "probe.example", want, addr(srv))
+ cmd.Env = append(cmd.Environ(), "GATE_PORT="+port(srv), "GATE_INSECURE=1", "GATE_SKIP_PUBLIC=1")
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+ if out, err := run(ok, version); err != nil {
+ t.Fatalf("a serving edge failed the gate: %v %s", err, out)
+ }
+ if out, err := run(unknown, version); err == nil || !strings.Contains(out, "FAIL") {
+ t.Fatalf("an unknown-host 404 passed the gate: %s", out)
+ }
+ if out, err := run(ok, "other"); err == nil || !strings.Contains(out, "FAIL") {
+ t.Fatalf("a version mismatch passed the gate: %s", out)
+ }
+}
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `go test . -run TestRegionServiceGate -count=1`
+Expected: FAIL, because the script does not exist.
+
+- [ ] **Step 3: Write the script**
+
+```bash
+#!/usr/bin/env bash
+# scripts/region-service-gate.sh <probe-host> <expected-version> [<edge-eip> ...]
+#
+# Nearest-region spec §17 step 5 (codex R4-3): before CONSOLE_APP_REGIONS
+# goes on, every eligible region must serve a fresh probe instance through
+# the REAL public path. Run once per region's probe host:
+# - from outside, through public DNS as it stands (skip with GATE_SKIP_PUBLIC=1);
+# - through every armed edge that is NOT the instance's box, via --resolve.
+# Passes only on 2xx with X-Carlos-Version equal to the deployed build.
+# An unknown-host 404 (backhaul with no mesh key), a TLS failure or a
+# version mismatch fails. GATE_PORT and GATE_INSECURE exist for tests only.
+set -uo pipefail
+host="${1:?usage: region-service-gate.sh <probe-host> <expected-version> [<edge-eip> ...]}"
+want="${2:?usage: region-service-gate.sh <probe-host> <expected-version> [<edge-eip> ...]}"
+shift 2
+port="${GATE_PORT:-443}"
+insecure=""
+[ "${GATE_INSECURE:-}" = 1 ] && insecure="-k"
+fail=0
+check() { # <label> [curl args...]
+ local label="$1"; shift
+ local headers status version
+ # $insecure is unquoted on purpose: empty must expand to nothing (an empty
+ # array under set -u fails on macOS's bash 3.2).
+ headers="$(curl -s $insecure -D - -o /dev/null --max-time 20 "$@" "https://$host:$port/")" || {
+ echo "FAIL $label: request failed"; fail=1; return; }
+ status="$(printf '%s' "$headers" | awk 'toupper($1) ~ /^HTTP/ {s=$2} END {print s}')"
+ version="$(printf '%s' "$headers" | awk -F': *' 'tolower($1)=="x-carlos-version" {gsub(/\r/,"",$2); print $2}')"
+ if [[ "$status" != 2* ]]; then echo "FAIL $label: status $status"; fail=1; return; fi
+ if [ "$version" != "$want" ]; then echo "FAIL $label: X-Carlos-Version '$version', want '$want'"; fail=1; return; fi
+ echo "ok $label: $status $version"
+}
+[ "${GATE_SKIP_PUBLIC:-}" = 1 ] || check "public DNS"
+for eip in "$@"; do
+ check "via edge $eip" --resolve "$host:$port:$eip"
+done
+exit "$fail"
+```
+
+`chmod +x scripts/region-service-gate.sh`.
+
+- [ ] **Step 4: Run it to verify it passes**
+
+Run: `go test . -run TestRegionServiceGate -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/region-service-gate.sh region_service_gate_test.go
+git commit -m "scripts: region service gate for the production switch (nearest-region §17 step 5)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 24: Docs and release notes (copy-review gate)
+
+**Files:**
+- Modify: `docs/site/cli.md` (`### carlos apps` `:112`, `### carlos deploy` `:211`, `### carlos canary` `:282`, `### carlos instances` `:540`)
+- Modify: `README.md` (`## Releasing the platform` `:227`; the console env section; the `STATUS_BOX_REGION` entry `:578`)
+- Test: the existing `internal/docsite` gate
+
+**Interfaces:**
+- Consumes: the flags from Tasks 20 to 22, the env from Task 9, and the
+ script from Task 23.
+
+- [ ] **Step 1: Draft the `cli.md` additions**
+
+Under `### carlos apps`, after the existing `carlos apps create` example:
+
+````markdown
+`--region <code>` sets the region the app's instances start in. Without it,
+the console picks the region nearest you from your timezone, and the command
+prints one line saying which and why:
+
+```
+carlos apps create --app hello --region uk
+```
+````
+
+Under `### carlos deploy`:
+
+```markdown
+When `carlos deploy` creates your first app, it sends your timezone so the
+console can pick the region nearest you, and prints the choice. `--region
+<code>` chooses instead. On an app that already exists, `--region` must
+match the app's region, or deploy stops before shipping anything.
+```
+
+Under `### carlos canary`:
+
+```markdown
+`--region <code>` places a new canary in that region (default: the app's
+region). It is refused for an existing canary in another region, and for an
+app that runs on one of your fleets.
+```
+
+Under `### carlos instances`, in the `create` paragraph:
+
+```markdown
+Without `--region`, a new instance starts in the app's region.
+```
+
+- [ ] **Step 2: Draft the README additions**
+
+In the console configuration list:
+
+```markdown
+- `CONSOLE_APP_REGIONS`: `on` lets new apps be given a region other than the
+ home. It needs `CONSOLE_HOME_REGION` set too. With the home unnamed, the
+ console logs once and leaves app regions off. Default off.
+- `CONSOLE_GEOIP_DB`: the path to an operator-supplied MaxMind-format `.mmdb`.
+ DB-IP Lite is recommended, and it requires the attribution the new-app page
+ shows. Unset or unusable turns the IP step of the region guess off, and the
+ console starts either way. Replace the file by atomic rename, then restart.
+```
+
+In `## Releasing the platform`:
+
+```markdown
+**App regions rollback floor.** Once any app holds a non-home region
+(`CONSOLE_APP_REGIONS` has been on), do not roll the console binary below
+the first release that understands app regions: an older console places a
+no-region create at the home. Name that release here and in its release
+notes. The switch is the feature's rollback lever: turning it off stops new
+non-home regions and keeps existing ones in force. On every start the console
+logs "possible placement discrepancy" lines for review.
+
+**Turning app regions on.** In order: roll the box release that publishes
+`serves_shared_pool` and `accepts_inbound` to every box and confirm every
+region's edges files carry them; roll the console with the switch off; on
+dev, turn it on and check an app row and an instance record; then, on
+production with the switch still off, create one probe instance per eligible
+region with an explicit `--region`, deploy a known build, and run
+`scripts/region-service-gate.sh <probe-host> <build> <every other edge EIP>`
+for each. Turn the switch on only when every region passes. Delete the
+probes. Re-run the gate after any period with the switch off and whenever a
+new region becomes eligible.
+```
+
+At the `STATUS_BOX_REGION` entry:
+
+```markdown
+ Keep it geographically consistent with the region's place name in the
+ console's region picker (for example "EU · Ireland" beside
+ "Ireland · Dublin"); it may stay coarser.
+```
+
+Beside the instances manifest `regions` documentation, add one sentence: "A
+manifest's `regions` allow-list compares the home as `""`, so listing the
+home's code does not admit the home."
+
+- [ ] **Step 3: Run the docsite gate**
+
+Run: `go test ./internal/docsite -count=1`
+Expected: PASS. No new command headings are needed, and the fenced examples
+use existing verbs.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docs/site/cli.md README.md
+git commit -m "docs: --region flags, CONSOLE_APP_REGIONS, CONSOLE_GEOIP_DB, rollback floor and switch runbook (drafts pending review)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+- [ ] **Step 5: Copy-review gate**
+
+Invoke `copy-review` with every paragraph added in steps 1 and 2, plus the
+gate script's `ok` and `FAIL` lines from Task 23. Apply Paul's edits
+verbatim, re-run step 3, and commit as `docs: region docs as approved`.
+**Do not land the branch before this closes.**
+
+---
+
+### Task 25: Whole-branch gate
+
+**Files:** none new.
+
+- [ ] **Step 1: Run the full gate**
+
+Run, from the worktree root, with an on-disk `TMPDIR` for console tests and
+`/tmp/g` for socket tests:
+
+```bash
+mkdir -p /tmp/g
+go build ./...
+GOOS=darwin go build . && GOOS=windows go build . && GOOS=linux GOARCH=arm64 go build ./...
+TMPDIR=/tmp/g make test
+```
+
+Expected: every package passes, and `gofmt -l .: clean`.
+
+- [ ] **Step 2: Confirm the copy gates closed**
+
+Run: `git log --oneline | grep -c 'as approved'`
+Expected: 5 (Tasks 4, 6, 7, 18 and 24). If fewer, the branch is not ready to
+land.
+
+- [ ] **Step 3: Confirm nothing touches DNS steering**
+
+Run: `git diff main --stat -- internal/steering internal/host/steering_loop.go infra/ && git diff main -- internal/console/apiinstances.go | grep -n 'Steering'`
+Expected: no files under steering or infra, and no added `Steering`
+assignments (R8).
+
+- [ ] **Step 4: Report**
+
+Write the amadan branch description (`amadan branch describe`). It covers
+what changed, the copy-review approvals, and that rollout follows README
+"Turning app regions on". Do not push without the person directing this work.
+
+---
+
+## Rollout (operational, after the branch lands; spec §17)
+
+These are not code tasks. They are listed so that whoever lands the branch
+runs them in order.
+
+1. **Before any roll.**
+ - Audit production manifests for `regions`.
+ - Confirm that every box publishes its AWS region. On the flagship, the
+ Ireland box should publish `region: eu-west-1` and no `placement`. Use
+ `CARLOS_AWS_REGION` where needed.
+ - Set `CONSOLE_HOME_REGION` on every console that will turn the switch on.
+2. **Box release.** Roll it to every box (Task 1), then confirm each
+ region's `control/edges/*.json` carries `serves_shared_pool` and
+ `accepts_inbound`.
+3. **Console release with `CONSOLE_APP_REGIONS` unset.** Behaviour is
+ unchanged, and it can be rolled back freely.
+4. **Dev.** Turn the switch on with two eligible regions. Create an app in
+ the browser and read its row. Create an instance with no region and read
+ its record. Diff `/regions` for an old app against the pre-roll answer.
+5. **Production service gate, switch still off** (Task 23 script). For each
+ eligible region:
+ - create a probe instance with an explicit `--region`;
+ - deploy a known build;
+ - run the gate from outside and through every other edge.
+
+ Every region must pass. Then delete the probes.
+6. **Flagship: turn the switch on.** From here the R7 rollback floor applies.
+ Name the floor version in the release notes and in the
+ carloku-infrastructure roll recipe. Optionally, set `CONSOLE_GEOIP_DB`.
+7. **CLI release.**
+8. **titogo**, in its own repo, adopts `POST …/regions/guess` and the place
+ names.
+
+**Do not arm `CARLOS_STEERING_ZONE_ID` anywhere** (spec §13.3). That is the
+follow-on spec's.
+
+---
+
+## Self-review
+
+1. **Spec coverage.**
+
+ | Spec | Covered in |
+ | --- | --- |
+ | §5.1 to §5.4 (columns, rebuild, atomic insert, backfill) | Task 8 |
+ | §5.5 (explicit vs guess, adoption, `@auto`) | Tasks 12, 13 |
+ | §5.6 (switch, named home) | Tasks 9, 12 |
+ | §5.7 (floor, discrepancies) | Tasks 17, 24 |
+ | §6.1 (candidates, eligibility, policy, read failure) | Tasks 1, 10 |
+ | §6.2 (points) | Tasks 3, 4, 5 |
+ | §6.3, §6.4 (nearest, invariant) | Tasks 2, 11 |
+ | §6.5 (privacy) | Task 15's redirect test, Task 12's log test |
+ | §7 (fill, eligibility, C16, C17, C26, C28, response) | Task 14 |
+ | §8 (every create path) | Tasks 12, 13, 14, 20, 22 |
+ | §9.1 to §9.5 | Tasks 12, 14, 15, 13 |
+ | §9.6 (compatibility) | Tasks 20, 22: old-console assertions |
+ | §10 | Tasks 13, 16 |
+ | §11 | Tasks 19 to 22 |
+ | §12 (fleets, pools, replicas, moves, static, sidecars unchanged) | Tasks 14, 15 |
+ | §13 (no stamp, gate) | Task 25 step 3 and Rollout |
+ | §14 | Task 4 and Task 24's `STATUS_BOX_REGION` line |
+ | §15 failure modes | Spread across the tests named above |
+ | §16 tests | Each listed test is in its owning task |
+ | §17 | Task 23, Task 24, Rollout |
+ | §19 copy | Tasks 4, 6, 7, 18, 24 |
+
+ Moves need no code: a move never touches the app row, and Task 8 adds no
+ move hook.
+2. **Placeholder scan.** Each task gives the code it needs. Where a fact has
+ to be confirmed in the codebase, the step says how to check it and what
+ to do if it differs. Those facts are:
+ - the `fx.st.db` field and `fx.srv.redirects` field names;
+ - `ListRecords`' `max` semantics;
+ - a fixture IP in MaxMind's test data;
+ - the `apiInstanceRecord` field names.
+3. **Type consistency.**
+ - `regionCandidate`, `regionPolicy.allows`, `guessRegion`,
+ `guessInput`/`guessResult`, `regionReq{explicit, hasExplicit, tz, ip}`,
+ `appDefaultView` and its CLI mirror `apiAppDefault`, `autoRegion`,
+ `homeOnlyCandidates`, `findCandidate` and `eligibleCount` are each
+ defined once and used with the same signatures.
+ - `createApp` gains `region, timezone` in Task 20, and every caller is
+ updated there.
+ - `instanceCreatedLine(rec, envSuffix)` has one signature throughout
+ Task 22.
+4. **Review Focus.** Five items, each with a test in its owning task: Tasks
+ 19 and 3, Task 5, Task 13, Task 15 (with Task 11's core), and Task 12.