carlos / platform Public

geoip spec rev 1: platform GeoIP headers, mirrored database, feed, endpoint (#241)

Human

Prompt

Can we pick up discussion 241 and make a plan to implement?

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

Design for exposing the platform's GeoIP lookup to apps, as approved in conversation, plus the Astra review brief.
claude agent, operated by paul@keymail.dev 5e1129beb8fcfda2404e2e6149bac9daed7ad4e4 parent 9bed97b
2 files changed, +625 −0
  • docs/superpowers/specs/2026-10-06-platform-geoip-design.md +559 −0
  • docs/superpowers/specs/CODEX-REVIEW-BRIEF-GEOIP.md +66 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-10-06-platform-geoip-design.md b/docs/superpowers/specs/2026-10-06-platform-geoip-design.md
new file mode 100644
index 000000000..78091617f
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-06-platform-geoip-design.md
@@ -0,0 +1,559 @@
+# Platform GeoIP for apps: headers, a mirrored database, a lookup endpoint
+
+**Date:** 2026-10-06
+**Revision:** 1
+**Discussion:** amadan carlos/platform #241
+**Branch:** `geoip-for-apps`
+
+Rev 1 records the design Paul approved in conversation on 2026-10-06. It has
+not been reviewed by a second model yet.
+
+## 0. Rulings
+
+Paul's answers during brainstorming. Do not reopen them without him.
+
+- **R1. The database reaches the edges from the bucket.** The operator (or
+ the feed, R6) publishes once. Every edge mirrors it and swaps its reader
+ in place, with no restart. This replaces #241's "atomic rename plus
+ restart" rule for the edge. `CONSOLE_GEOIP_DB` keeps that rule as an
+ override (§11.3).
+- **R2. Four headers:** country, subdivision, coordinates and source (§7).
+- **R3. Tunnel and fleet boxes trust the hub.** The hub computes the
+ headers. The remote edge keeps them only on requests that arrived over
+ the authenticated tunnel (§8.3). Remote boxes hold no database.
+- **R4. No per-app switch.** Headers go to every app whenever a database
+ is loaded. A switch can ride the telemetry settings pipeline later if
+ someone asks for one.
+- **R5. One spec, three plans:** headers first, then the feed, then the
+ endpoint (§16). Analytics countries ship in plan 1.
+- **R6. The database updates itself.** The console fetches new editions of
+ DB-IP Lite or GeoLite2 and publishes them through the same path as a
+ manual publish (§10).
+
+## 1. Why, and what this is not
+
+The nearest-region work gave the console a GeoIP reader (`internal/geo`,
+`CONSOLE_GEOIP_DB`). It serves one caller: guessing a new app's region.
+Once the platform carries the database, every app should be able to ask
+"roughly where is this visitor" without shipping its own `.mmdb`, handling
+attribution, or tracking updates.
+
+Goals:
+
+- Every proxied request to an app carries the visitor's country, and where
+ the database has them, subdivision and coordinates.
+- Apps can look up an arbitrary IP (batch jobs, logs, webhooks).
+- The platform's own analytics fill the `countries` map the analytics spec
+ reserved for this (`2026-09-07-errors-and-analytics-design.md`).
+- The operator publishes or configures a feed once per deployment, not once
+ per box.
+
+Not goals:
+
+- **Accuracy beyond the database.** The headers say what the database says.
+- **Header injection for traffic that never crosses the edge.** Scheduler
+ calls go straight to the socket. Sidecars reach their instance through
+ loopback.
+- **ASN, ISP, timezone or city-name headers.** Additive later if asked for.
+- **A second reader.** Everything builds on `internal/geo`.
+
+## 2. Overview
+
+```
+operator ──carlos system geoip publish──┐
+ ▼
+feed (console, daily) ──────────► console publish path
+ │ verify, upload blob, flip pointer
+ ▼
+ bucket: control/geoip/current.json → control/geoip/<sha256>.mmdb
+ │
+ every edge polls the pointer (10 min)
+ ▼
+ /data/carlos/geoip/current.mmdb → geo.Swappable (hot swap)
+ │
+ ServeHTTP: strip X-Carlos-Geo-*, look up clientIP(r), set headers
+ │ │
+ ▼ ▼
+ proxied request to the app analytics Obs.Country
+ → Day.Countries
+console: reads the same mirrored file → GET /api/cli/apps/{a}/{app}/geo?ip=
+ carlos geo <ip>
+```
+
+## 3. Bucket records
+
+The console is the **only writer** of `control/geoip/`. Edges only read it.
+
+**The blob** is `control/geoip/<sha256>.mmdb`. Its name is the hex SHA-256
+of its bytes, so a blob never changes after it is written.
+
+**The pointer** is `control/geoip/current.json`:
+
+```json
+{
+ "v": 1,
+ "sha256": "9f2c…",
+ "key": "control/geoip/9f2c….mmdb",
+ "size": 131072000,
+ "database_type": "DBIP-City-Lite",
+ "build_epoch": 1790812800,
+ "published": "2026-10-06T21:00:00Z",
+ "origin": "feed:dbip-city-lite",
+ "edition": "2026-10"
+}
+```
+
+- `database_type` and `build_epoch` come from the file's own MMDB metadata.
+- `origin` is `manual` or `feed:<name>`. `edition` is the feed's own edition
+ marker: the `YYYY-MM` for DB-IP, the `Last-Modified` value for MaxMind,
+ and empty for a manual publish.
+- **No pointer means the feature is off.** An unknown `v` is treated as no
+ pointer, logged once.
+
+**Publish order:** the blob first, then the pointer, written with `ifMatch`
+on the pointer's previous etag so two publishers can't interleave. The
+previous blob is deleted only after the new pointer is written. An edge
+that read the old pointer moments before can find its blob gone. It
+retries on the next poll and reads the new pointer then.
+
+## 4. Publishing
+
+### 4.1 The console's publish path
+
+`geoipPublish(ctx, src io.Reader, origin, edition string)`, used by both
+the CLI verb and the feed:
+
+1. Stream `src` to a temporary file under the console's data directory,
+ hashing as it goes. Refuse anything over 1 GiB.
+2. Open it with `geo.OpenMMDB`, which verifies it.
+3. Refuse a database with no country data. Probe: the reader's metadata
+ lists a `country` field in its first record, or a lookup of a fixed set
+ of well-known public addresses yields a country for at least one of
+ them. The plan settles which check, against the real files.
+4. Refuse a database whose search tree has fewer than half the nodes of
+ the current one (`metadata.node_count`). This guards against a truncated
+ or wrong-product file. A manual publish can override it with `-force`.
+ The feed can't.
+5. Upload the blob, write the pointer, delete the old blob (§3).
+6. Delete the temporary file.
+
+### 4.2 Operator verbs
+
+Under `carlos system`, through the console API like `carlos system
+antivirus`, for system operators only (`CARLOS_SYSTEM_OPERATORS`):
+
+- `carlos system geoip publish <file.mmdb> [-force]` uploads the file to
+ the console, which runs §4.1. The body is streamed, not buffered.
+- `carlos system geoip status` shows:
+ - the pointer (type, edition, build date, age, origin);
+ - the feed's configuration and its last result;
+ - the sha each box reports having loaded (§5.4), so "has every edge
+ picked it up" is one command.
+- `carlos system geoip off` deletes the pointer, then the blob. Every edge
+ drops its reader within one poll. The feed (§10) does not republish
+ while the pointer is absent and the feed is unset. If the feed is set,
+ `off` warns that the feed will publish again tomorrow and says to unset
+ `CONSOLE_GEOIP_FEED`.
+
+## 5. The edge mirror
+
+### 5.1 Where and who
+
+- The mirror runs inside `carlos agent`'s edge on every box that reads the
+ deployment bucket directly.
+- **It does not run on boxes that use the tunnel client** (`CARLOS_TUNNEL_*`
+ set). Those get their headers from the hub (§8.3).
+- Files live under `/data/carlos/geoip/`, which already sits inside the
+ edge unit's `ReadWritePaths`. The directory is mode 0755 and
+ `current.mmdb` is 0644, so the console (user `carlos-console`, on the
+ console box) can read the same file (§11.3). The data isn't secret.
+
+### 5.2 The loop
+
+- **At boot:** if `current.mmdb` exists, open it straight away. Serving
+ never waits on the bucket. The sha it was published under is kept in
+ `current.sha256` next to it.
+- **Every 10 minutes**, with jitter, read the pointer:
+ - **Same sha as loaded:** nothing.
+ - **New sha:**
+ 1. Stream the blob to `.incoming` and check its sha and size against
+ the pointer.
+ 2. Open it with `geo.OpenMMDB`, off the request path.
+ 3. Rename it to `current.mmdb`, write `current.sha256`, and swap the
+ reader (§5.3).
+ - **No pointer:** swap in a nil reader, then delete the local files.
+ - **The read fails** (network, permissions): keep what is loaded. Log
+ once per hour while it keeps failing.
+- **A blob that fails its sha or verification** is logged once per sha and
+ not retried until the pointer changes.
+
+The current `release.Store.Get` returns the whole body as `[]byte`. For a
+DB-IP City file (about 130 MB) that's too much to hold on a small box, so
+plan 1 adds an optional streaming read:
+
+```go
+// release.StreamGetter is implemented by stores that can stream a body.
+type StreamGetter interface {
+ GetTo(ctx context.Context, key string, w io.Writer) (etag string, err error)
+}
+```
+
+The S3 store and the directory store implement it. The mirror uses it when
+it's there and falls back to `Get` when it isn't.
+
+### 5.3 Hot swap
+
+`geo.Swappable` wraps the reader:
+
+```go
+type Swappable struct {
+ mu sync.RWMutex
+ cur *MMDB
+}
+func (s *Swappable) Lookup(ip net.IP) (Result, bool) // RLock for the lookup
+func (s *Swappable) Swap(next *MMDB) // Lock, replace, Unlock, then Close the old one
+```
+
+The old mapping is closed only after the write lock has been taken and
+released. No lookup is ever in flight on an unmapped file. A lookup holds
+the read lock for a few microseconds, so a swap waits that long at most.
+The swap needs a `-race` test with concurrent lookups (§14).
+
+### 5.4 Reporting
+
+Each box's status heartbeat (`status/boxes/<label>.json`) gains:
+
+```json
+"geoip": {"sha256": "9f2c…", "type": "DBIP-City-Lite", "error": ""}
+```
+
+- The block is omitted entirely on tunnel boxes.
+- `carlos system geoip status` reads it (§4.2).
+
+## 6. `internal/geo` changes
+
+`Locate` stays as it is for the console's region guess. Lookups that need
+more get a new method:
+
+```go
+type Result struct {
+ Country string // ISO 3166-1 alpha-2; country, else registered country
+ Subdivision string // ISO 3166-2 like "GB-SCT"; empty unless the database has an ISO code
+ Point Point
+ HasPoint bool // true only for a real location record, never a centroid
+}
+func (m *MMDB) Lookup(ip net.IP) (Result, bool)
+func (m *MMDB) Source() string // metadata.DatabaseType, reduced to [A-Za-z0-9._-]
+```
+
+- `Lookup` applies the same `Locatable` filter, `To4` normalisation,
+ `onTheGlobe` check and fixed-category error log as `Locate`.
+- A malformed location makes the whole record a miss, as it does for
+ `Locate`. A record wrong in one field isn't trusted on the next.
+- `ok` is false when no country can be found.
+- `Subdivision` is `country + "-" + subdivisions[0].iso_code`, only when
+ both are present and the code is 1 to 3 `[A-Z0-9]` characters.
+- **DB-IP Lite has subdivision names but no ISO codes**, so with DB-IP the
+ subdivision is always empty. With GeoLite2 City it is usually filled.
+- `Locate` is reimplemented on top of `Lookup` so the two can't drift. A
+ record with no point still falls back to the country's representative
+ point for `Locate`, as today.
+
+## 7. The header contract
+
+| Header | Value | Present when |
+|---|---|---|
+| `X-Carlos-Geo-Country` | ISO 3166-1 alpha-2, upper case | a country was found |
+| `X-Carlos-Geo-Subdivision` | ISO 3166-2, e.g. `GB-SCT` | the database has an ISO subdivision code (GeoLite2, not DB-IP Lite) |
+| `X-Carlos-Geo-Lat` | decimal degrees, 2 places | the record has a real location |
+| `X-Carlos-Geo-Lon` | decimal degrees, 2 places | the same |
+| `X-Carlos-Geo-Source` | the database type, e.g. `DBIP-City-Lite`, `GeoLite2-City` | any geo header is present |
+
+- All five are absent on a miss, on an address that isn't locatable, or
+ when no database is loaded. The platform never guesses.
+- Two decimal places is about 1 km, roughly the precision of these
+ databases anyway. The edge rounds before setting the header.
+- Apps can already see the client IP in `X-Forwarded-For`, so these headers
+ tell an app nothing it couldn't work out for itself.
+- Apps that vary a cached response on a geo header must send `Vary` on it.
+ The edge cache honours `Vary` with at most 8 variants per URL
+ (`internal/edge/cache.go`). Country-level variation fits that budget.
+ Coordinate-level variation does not, and the docs say so.
+
+## 8. Trust: who may set the headers
+
+### 8.1 The strip
+
+`ServeHTTP` deletes every `X-Carlos-Geo-*` header from the inbound request
+on **every** request: every route, every proxy type, before any branch.
+The one exception is §8.3. A test sends forged headers through each of the
+six proxy types (unix, tcp, multi, tunnel, fleet, backhaul) and asserts the
+app never sees them.
+
+### 8.2 Setting them
+
+After the strip, the edge looks up `clientIP(r)` and sets the headers. This
+happens after `acceptHop`, the route lookup and the gate, and **before**
+the upgrade branch and the cache lookup, so WebSocket upgrades carry them
+and `Vary` matching sees the right values. The result is also put on the
+request context so analytics reuses it (§9) instead of looking up twice.
+
+| Path | What happens |
+|---|---|
+| Direct request | `clientIP` is `RemoteAddr`. Look up. |
+| Verified backhaul hop (home edge) | `clientIP` is the last XFF element, trusted only under `hopVerifiedKey`. The home edge strips and recomputes. It doesn't trust the sending edge's copy. |
+| Backhaul sender | Strips like any edge, then forwards. It doesn't compute, because the home edge will. |
+| Unverified hop | Treated as a direct request from the sender's address, as today. |
+| Door (edge-local socket) | **No geo headers.** The door replaces `RemoteAddr` with the box's own public address, so a lookup would locate the box. |
+| Sidecar through loopback | The address isn't locatable, so there are no headers. |
+| Static sites served by the edge | No proxied request, so nothing to set. |
+
+### 8.3 Tunnel and fleet boxes
+
+The hub edge strips and sets the headers like any edge, then `tunnelProxy`
+or `fleetProxy` forwards them. On the remote box:
+
+- `tunnelclient` serves the edge over the inverted HTTP/2 connection.
+ Plan 1 wraps that handler so every request it serves carries a new
+ context mark, `tunnelKey{}`. Nothing else may set that mark, the same
+ property `hopVerifiedKey` rests on.
+- The remote edge's strip skips requests that carry `tunnelKey`, and it
+ doesn't look up for them either (its `RemoteAddr` is the hub, which would
+ locate the hub). The hub's headers reach the app unchanged.
+- A remote box has no public listener for app traffic. If an operator ever
+ configures one, requests through it don't carry `tunnelKey` and get
+ stripped, so a forged header still can't get through.
+
+A test drives a request through a real `tunnelclient` connection and checks
+that the hub's headers arrive. Another test sends forged headers to the
+remote edge's own listener and checks they are stripped.
+
+### 8.4 Mixed versions during a roll
+
+An edge still on an old binary doesn't strip `X-Carlos-Geo-*`. That is
+true today for every edge. The contract is not real until every edge runs
+the new binary. So the rollout order (§15) is: roll every box, **then**
+publish a database, **then** publish the app docs.
+
+## 9. Analytics countries
+
+- `analytics.Obs` gains `Country string`. `observeAnalytics` reads it from
+ the request context (§8.2) and doesn't look up again.
+- `Delta` and the flush fold `Country` into `Day.Countries`. An empty
+ country counts as `"_unknown"`. The map is bounded like the other
+ summaries: there are fewer than 260 ISO codes, so in practice it never
+ hits the `_other` overflow, but the bound is enforced anyway.
+- The console's analytics page (`app_analytics.html`) gains a Countries
+ table: the top 10, then "other".
+- **The page shows the attribution its database requires** (§13), chosen
+ from the pointer's `database_type`. The new strings go through
+ copy-review before they're written.
+- Days before plan 1 rolls have an empty map. The table says "no country
+ data for this period" rather than showing nothing.
+
+## 10. The feed (plan 2)
+
+The console fetches new editions itself. It is the one control-plane
+process per deployment, so there is only ever one fetcher.
+
+### 10.1 Configuration (console env)
+
+| Variable | Meaning |
+|---|---|
+| `CONSOLE_GEOIP_FEED` | `dbip-city-lite`, `dbip-country-lite`, `geolite2-city`, `geolite2-country`; unset means no feed |
+| `CONSOLE_GEOIP_MAXMIND_ACCOUNT` | MaxMind account ID, for `geolite2-*` |
+| `CONSOLE_GEOIP_MAXMIND_KEY` | MaxMind license key, for `geolite2-*` |
+
+An unknown feed name, or a GeoLite2 feed with no credentials, is logged
+once at startup. The feed stays off and the console still starts.
+
+### 10.2 Checking for a new edition
+
+The console checks once a day, at a jittered time, and once 5 minutes after
+it starts.
+
+- **DB-IP:** the edition is `YYYY-MM`, from
+ `https://download.db-ip.com/free/dbip-<city|country>-lite-YYYY-MM.mmdb.gz`.
+ If the pointer's `edition` isn't this month, it tries this month's URL. A
+ 404 just means it isn't out yet, so it tries again tomorrow. When nothing
+ has been published yet, it tries this month and then last month.
+- **MaxMind:** it HEADs
+ `https://download.maxmind.com/geoip/databases/GeoLite2-<City|Country>/download?suffix=tar.gz`
+ with Basic auth and compares `Last-Modified` with the pointer's
+ `edition`. MaxMind documents HEAD as not counting against the download
+ limit. When the edition differs, it fetches `suffix=tar.gz.sha256` and
+ then the archive.
+
+### 10.3 Integrity and safety
+
+- The download streams to disk and is decompressed: `.gz` for DB-IP, the
+ single `.mmdb` member of the `.tar.gz` for MaxMind. Decompressed output
+ is capped at 1 GiB, so a decompression bomb can't fill the disk.
+- MaxMind's archive is checked against its published SHA-256. DB-IP
+ publishes no checksum that the console can fetch, so it relies on TLS,
+ `Verify` and §4.1's checks.
+- The feed publishes only when the new database's `build_epoch` is
+ **newer** than the current pointer's and its `database_type` **matches**
+ the current one. A feed can't replace a City database with a Country one
+ or roll back to an older edition. The one exception is when the current
+ pointer is a manual publish of a different type: the feed logs that
+ it's replacing it and goes ahead, because configuring a feed is the
+ operator saying which database they want.
+- If the pointer is absent and the feed is set, the feed publishes. That
+ is how the first publish happens on a fresh deployment.
+- On any failure the current database stays. Each edition's failure is
+ logged once.
+
+### 10.4 Staleness
+
+When the pointer's `build_epoch` is more than 45 days old, the console
+raises a deployment-level alert, `geoip_stale`, through its existing alert
+watcher (`internal/console/alertwatch.go`). This happens whether or not a
+feed is set. The geo data keeps being served. Email to the grant
+recipients works. `CARLOS_ALERT_WEBHOOK` is still unset on the flagship
+(#192), so the webhook leg delivers nowhere until that is fixed.
+
+## 11. The lookup endpoint and CLI (plan 3)
+
+### 11.1 Endpoint
+
+`GET /api/cli/apps/{account}/{app}/geo?ip=<addr>`, registered through
+`cliRoute`:
+
+- **Allowed:** the instance token (`svcrole.Instance`), and the member and
+ app tokens that can read the app's analytics.
+- **Rate limit:** the existing per-caller limiter at 60 a minute. The
+ intended use is occasional lookups, not bulk enrichment; a bulk form can
+ come later if anyone needs it.
+- **200** `{"found": true, "country": "IE", "subdivision": "", "lat": 53.34, "lon": -6.27, "source": "DBIP-City-Lite", "attribution": "IP Geolocation by DB-IP", "attribution_url": "https://db-ip.com"}`.
+ The fields follow §7: absent values are omitted, and the coordinates
+ are rounded the same way.
+- **200** `{"found": false}` for an address that isn't locatable or isn't
+ in the database.
+- **400** for a missing or malformed `ip`.
+- **503** `geo unavailable` when no database is loaded.
+- The console never logs the queried IP.
+
+### 11.2 CLI
+
+`carlos geo <ip> [--app <app>]` calls the endpoint and prints the fields
+and the attribution. `--json` prints the response as is. With no app
+context it uses the current app, like the other app verbs.
+
+### 11.3 The console's reader
+
+The console reads `/data/carlos/geoip/current.mmdb`, the file the console
+box's own edge mirrors. Every minute it checks the file's inode and swaps
+the reader (`geo.Swappable`) when the inode changes. Region guesses and the
+endpoint share that one reader.
+
+`CONSOLE_GEOIP_DB`, when set, overrides the mirrored path and keeps its
+current rules: it's opened once, and replacing it means a rename plus a
+restart. That keeps existing deployments working unchanged.
+
+## 12. Logging
+
+Every message is a fixed category and **never includes an IP address**.
+
+| Where | Message | Rate |
+|---|---|---|
+| edge mirror | `edge: geoip database <sha8> (<type>, built <date>) loaded` | per swap |
+| edge mirror | `edge: geoip turned off; no database published` | per transition |
+| edge mirror | `edge: geoip blob <sha8> rejected: <category>` | once per sha |
+| edge mirror | `edge: geoip pointer unreadable; keeping the loaded database` | hourly while failing |
+| geo | `geo: a GeoIP lookup failed and was treated as a miss` | once a minute, process-wide (existing) |
+| console | `console: geoip published <sha8> (<type>, <origin>)` | per publish |
+| console feed | `console: geoip feed <name>: edition <e> rejected: <category>` | once per edition |
+| console feed | `console: geoip feed misconfigured: <category>` | at startup |
+
+## 13. Licensing and docs
+
+- **DB-IP Lite** is licensed CC BY 4.0. Pages that show or use its data
+ need `IP Geolocation by DB-IP` with a link to `https://db-ip.com`.
+- **GeoLite2** is covered by MaxMind's EULA. It requires attribution
+ (`This product includes GeoLite2 data created by MaxMind, available from
+ https://www.maxmind.com`) and keeping the data current, which the feed
+ does.
+- **The operator owns compliance.** The operator docs say that plainly,
+ next to the publish verb and the feed settings.
+- **The app docs** list the headers, explain that they may be absent and
+ why, cover `Vary` and its limit, and spell out the duty: an app that
+ shows visitors anything derived from these headers or the endpoint must
+ carry the attribution `X-Carlos-Geo-Source` names. The endpoint returns
+ the attribution text so an app can render it.
+- **The platform's own pages** that use the data carry the attribution:
+ the analytics Countries table, and the new-app region guess if it ever
+ shows the visitor where it thinks they are.
+
+## 14. Testing
+
+- **`internal/geo`:**
+ - `Lookup` against the MaxMind fixtures already in `testdata`
+ (country, registered-country fallback, subdivision codes from
+ `GeoIP2-City-Test.mmdb`, no point from a Country database);
+ - malformed locations;
+ - `Locate` returning exactly what it did before (the existing tests
+ stay);
+ - `Source` sanitising.
+- **Swap:** `-race`, with 32 goroutines looking up while 100 swaps happen,
+ and a `Close` spy proving no close happens while a read lock is held.
+- **Mirror**, against the in-memory store:
+ - first publish, a new sha, the pointer removed, the pointer
+ unreadable;
+ - a blob with a sha mismatch, a blob that fails `Verify`;
+ - boot with a local file and no bucket;
+ - the old blob deleted between reading the pointer and fetching the
+ blob.
+- **Edge:**
+ - the strip test across all six proxy types;
+ - setting on a direct request;
+ - recomputing on a verified hop, never trusting a sender's copy;
+ - none on the door, none for loopback;
+ - the tunnel mark passing the hub's headers;
+ - a forged header on a tunnel box's own listener being stripped;
+ - headers present on a WebSocket upgrade;
+ - the cache's `Vary` variant selected by country;
+ - analytics `Countries` filled from the same lookup.
+- **Publish path:** each refusal in §4.1, the `ifMatch` race, the
+ old-blob delete.
+- **Feed**, against an `httptest` server standing in for DB-IP and MaxMind:
+ - a new edition;
+ - a 404 before the month's file appears;
+ - an older `build_epoch`, a type mismatch;
+ - a bad SHA-256, a decompression bomb;
+ - HEAD-only when `Last-Modified` hasn't changed;
+ - the staleness alert.
+- **Endpoint:** each status code, role refusals, the rate limit, and that
+ no IP ever reaches the log.
+
+## 15. Rollout
+
+1. Merge plan 1 and roll every box. There is no pointer yet, so nothing
+ changes. Strips happen, but there is nothing to strip.
+2. Confirm with `carlos system geoip status` that every box reports a
+ mirror with no database.
+3. Publish a database by hand (plan 1), or wait for plan 2's feed. Confirm
+ every box reports the sha.
+4. Check a request to a test app carries the headers. Check a forged header
+ from outside is replaced.
+5. Publish the app docs.
+
+Plans 2 and 3 roll the console only. The feed does nothing until
+`CONSOLE_GEOIP_FEED` is set.
+
+## 16. Plans
+
+| Plan | Contents |
+|---|---|
+| 1 | `geo.Lookup`, `geo.Swappable`, `release.StreamGetter`, the console publish path and `carlos system geoip publish|status|off`, the edge mirror and box status, strip and set, the tunnel mark, analytics `Countries` and the console table, operator and app docs |
+| 2 | The feed: DB-IP and MaxMind sources, integrity checks, monotonic publish, the `geoip_stale` alert |
+| 3 | The console reader on the mirrored file, the endpoint, `carlos geo` |
+
+## 17. Open items for the plans
+
+- **The country-data probe in §4.1.3** (metadata versus known addresses)
+ is decided against a real DB-IP Lite and GeoLite2 file in plan 1, task 1.
+ That task also confirms that DB-IP Lite has no subdivision ISO codes.
+- **DB-IP's site says nothing on automated downloads.** Plan 2 fetches at
+ most once a day, and only when a new month is due. If DB-IP objects, the
+ feed falls back to manual publishing with no code change.
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-GEOIP.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-GEOIP.md
new file mode 100644
index 000000000..cfd3e3ee8
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-GEOIP.md
@@ -0,0 +1,66 @@
+# Review brief: platform GeoIP for apps
+
+You are GPT-6 reviewing a design spec independently of its author.
+Your output is a findings report, not an edit. Do not modify any file.
+
+## What to read
+
+1. The spec under review:
+ `docs/superpowers/specs/2026-10-06-platform-geoip-design.md`.
+ §0's rulings are Paul's and fixed. Report a mechanism that fails one;
+ do not re-argue one.
+2. The originating request: amadan discussion carlos/platform #241, quoted
+ in the spec's §1. The nearest-region spec it builds on:
+ `docs/superpowers/specs/2026-10-01-nearest-region-default-design.md` §6.
+3. The code it fits into, in this worktree:
+ - `internal/geo/` (`mmdb.go`, `point.go`, `tz.go`), `cmd/console/main.go`
+ (`openGeoIP`);
+ - `internal/edge/edge.go` (`ServeHTTP`, the six proxies and their
+ `Rewrite` functions, `observeAnalytics` defer), `internal/edge/homemap.go`
+ (`acceptHop`, `setForwarded`, `stripForwardedHeaders`),
+ `internal/edge/analytics.go` (`clientIP`, `hopVerified`),
+ `internal/edge/door.go`, `internal/edge/cache.go`,
+ `internal/edge/certsync.go` (the precedent for mirroring from the bucket),
+ `internal/edge/purge_header.go`;
+ - `internal/tunnelclient/`, `internal/host/tunnel_client.go`,
+ `internal/host/agent.go`, `internal/host/units/carlos-edge.service`;
+ - `internal/release/store.go` (`Store`), the S3 store;
+ - `internal/analytics/` (`Day`, `Delta`, `Obs`, flush and merge);
+ - `internal/console/authcli.go` (`cliRoute`, roles, rate limiting),
+ `internal/console/alertwatch.go`, `system.go` (the `carlos system
+ antivirus` verbs as the precedent for operator verbs);
+ - `AGENTS.md`.
+
+## What matters most
+
+- **Forgery.** Find any path by which a client-chosen value reaches an app
+ in an `X-Carlos-Geo-*` header: any proxy, hop, tunnel, door, upgrade or
+ cache path the strip in §8 misses, or a way to get `tunnelKey` onto a
+ request that did not arrive over the tunnel.
+- **Wrong location.** Any path where the edge locates the wrong address
+ (the box itself, the hub, a sender edge, a proxy) and sets headers that
+ look authoritative.
+- **The hot swap (§5.3).** Can a lookup ever run against a closed mapping?
+ Any deadlock or starvation under load?
+- **The mirror and publish protocol (§3–§5).** Races between publishers,
+ between a publish and an edge poll, a deleted blob, a partially written
+ file, disk filling, a box restarting mid-swap, the console box reading a
+ file the edge is replacing.
+- **The feed (§10).** Supply-chain risks in fetching and publishing a file
+ every edge maps into memory; decompression limits; the monotonic rule;
+ credential handling; behaviour when DB-IP or MaxMind change URLs.
+- **Cache interaction (§7, §8.2).** Does setting headers before the cache
+ lookup interact correctly with `Vary`, the 8-variant limit and purge?
+- **Licensing (§13).** Is anything stated wrongly about DB-IP Lite's
+ CC BY 4.0 terms or MaxMind's GeoLite2 EULA?
+- **Claims about the code.** Every `file:line` and every "already exists"
+ claim: check it. Report each one that is wrong.
+- **Completeness for planning.** Anything an implementer would have to
+ invent because the spec is silent.
+
+## Output
+
+A numbered list of findings, most severe first, each with: severity
+(P1 = wrong or unsafe as written, P2 = gap an implementer would get wrong,
+P3 = clarity), the spec section, the code evidence with file:line, and a
+concrete fix. End with a one-line verdict: accept, or revise.