carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Platform GeoIP for apps: headers, a mirrored database, a lookup endpoint
2
3**Date:** 2026-10-06
4**Revision:** 13
5**Discussion:** amadan carlos/platform #241 (quoted in Appendix A)
6**Branch:** `geoip-for-apps`
7
8- Rev 1 (5e1129be) records the design Paul approved in conversation on
9 2026-10-06.
10- Rev 1 went to Astra (GPT-6, via codex). The findings are in
11 `CODEX-FINDINGS-GEOIP-R1.md`: 22 findings, 4 of them P1. Every one was
12 checked against the code and accepted. Changes are marked
13 "(codex R1-N)", and §18 records each disposition.
14- **Rev 2 changes the trust model (codex R1-3, R1-10).** The edge that
15 accepts the visitor's connection computes the headers. Downstream edges
16 trust them only over a verified hop or the tunnel. Rev 1 had the home edge
17 recompute, which located the sending box for a backhauled door request.
18- Rev 2 makes one decision Paul has not seen, **D1** in §0: GeoLite2
19 databases expire 30 days after they were built.
20- Rev 3 answers Astra's round 2 (`CODEX-FINDINGS-GEOIP-R2.md`). Of round
21 1's findings, 18 were resolved and 4 partly resolved. Round 2 adds 9 new
22 findings, 2 of them P1. Every one was checked and accepted, and changes
23 are marked "(codex R2-N)".
24 - Rev 3 adds an internal trust header, `X-Carlos-Hop-Geo`. It carries the
25 ingress edge's state (hit, miss, off) across a hop or the tunnel, and
26 no app ever sees it.
27 - It withholds geo on a rejected hop (and, until rev 7, on a plaintext tunnel; see D2).
28 - It adds a bucket lifecycle rule for GeoLite2's destroy duty.
29- Rev 4 answers Astra's round 3 (`CODEX-FINDINGS-GEOIP-R3.md`). That round
30 resolved all 9 of round 2's findings, left 2 earlier findings partly
31 resolved, and raised 4 new ones, 1 of them P1. Every one was accepted,
32 and changes are marked "(codex R3-N)".
33 - Rev 4 also folds in a code survey made for plan 1, marked "(survey)":
34 - the console's unit isn't in the repo;
35 - nothing creates `/data/carlos` yet;
36 - the existing staleness rule;
37 - the console's 5-minute read timeout;
38 - which boxes hold a store;
39 - there is no in-memory store type;
40 - there is no in-repo page listing the headers apps receive.
41- Rev 5 answers Astra's round 4 (`CODEX-FINDINGS-GEOIP-R4.md`). That round
42 resolved all of round 3, left 1 earlier finding partly resolved, and
43 raised 2 new P2s. All were accepted, marked "(codex R4-N)".
44 - Publishing becomes an upload followed by a job.
45 - A tunnelled request is counted once, by the hub.
46 - The rollout verdict reconciles against the edges records.
47- Rev 6 answers Astra's round 5 (`CODEX-FINDINGS-GEOIP-R5.md`): 1 new P2
48 and 2 partly resolved findings, all accepted, marked "(codex R5-N)".
49 - The upload slot is reserved before any body is read.
50 - Fleet boxes are reconciled through the registry.
51 - The tunnel analytics test is corrected.
52- Rev 7 answers Astra's round 6 (`CODEX-FINDINGS-GEOIP-R6.md`), marked
53 "(codex R6-N)".
54 - It takes **D2**, which reverses round 2's TLS-only tunnel trust.
55 - Uploads stage in a directory the console clears at startup.
56 - A box is retired explicitly, by deleting its edges record.
57- Rev 8 answers Astra's round 7 (`CODEX-FINDINGS-GEOIP-R7.md`), marked
58 "(codex R7-N)":
59 - an expired database is refused at publish;
60 - a source can never sanitise to empty;
61 - attributed sources are never folded into `_other`;
62 - the spec's signatures now match the committed code.
63- Rev 9 answers Astra's round 8 (`CODEX-FINDINGS-GEOIP-R8.md`), marked
64 "(codex R8-N)":
65 - box identity is (namespace, label);
66 - a download superseded mid-flight is never installed, and the poll
67 clock runs from the start of each pass;
68 - §3 says digest, not size;
69 - only expected boxes can block the verdict.
70- Rev 10 answers Astra's round 9 (`CODEX-FINDINGS-GEOIP-R9.md`), marked
71 "(codex R9-N)":
72 - the verdict knows four pointer states;
73 - `off` answers 409 during a publish instead of waiting;
74 - pointer reads are bounded at 30 seconds, and the off bound includes
75 them;
76 - an unreadable heartbeat blocks only through the expected box it hides.
77- Rev 11 answers Astra's round 10 (`CODEX-FINDINGS-GEOIP-R10.md`):
78 - the `CONSOLE_GEOIP_DB` override's D1 expiry moves into plan 1;
79 - the off bounds read 11.5 and 12.5 minutes (user copy rounds them up to
80 12 and 13);
81 - the verdict judges expiry by the clock.
82- Rev 12 answers Astra's round 11 (`CODEX-FINDINGS-GEOIP-R11.md`). The
83 alert's expired reason now matches §5.5 (marked, or past D1 by the
84 clock), so expiry never clears an alert while the bucket is still
85 unmarked.
86- **Astra's round 12 (`CODEX-FINDINGS-GEOIP-R12.md`) accepted rev 12 with no
87 findings.**
88- **Rev 13 records Paul's rulings of 2026-10-07:**
89 - he accepts D1 and D2 (now R7 and R8);
90 - the free DB-IP Lite is the default, and operators (the flagship
91 included) may upgrade to paid MaxMind through two more feeds,
92 `geoip2-city` and `geoip2-country` (R9).
93
94## 0. Rulings and decisions
95
96Paul's answers during brainstorming. Do not reopen them without him.
97
98- **R1. The database reaches the edges from the bucket.** The operator (or
99 the feed, R6) publishes once. Every edge mirrors it and swaps its reader
100 in place, with no restart. This replaces #241's "atomic rename plus
101 restart" rule for the edge. `CONSOLE_GEOIP_DB` keeps that rule as an
102 override (§11.3).
103- **R2. Four headers:** country, subdivision, coordinates and source (§7).
104- **R3. Tunnel and fleet boxes trust the hub.** The hub computes the
105 headers. The remote edge keeps them only on requests that arrived over
106 the authenticated tunnel (§8). Remote boxes hold no database.
107- **R4. No per-app switch.** Headers go to every app whenever a database
108 is loaded. A switch can ride the telemetry settings pipeline later if
109 someone asks for one.
110- **R5. One spec, three plans:** headers first, then the feed, then the
111 endpoint (§16). Analytics countries ship in plan 1.
112- **R6. The database updates itself.** The console fetches new editions of
113 DB-IP Lite or GeoLite2 and publishes them through the same path as a
114 manual publish (§10).
115
116Decisions taken during review. Paul hasn't seen these yet; each is easy to
117reverse.
118
119- **D1 (codex R1-4). GeoLite2 databases expire 30 days after their
120 `build_epoch`.**
121 - **Why:** MaxMind's GeoLite EULA requires the licensee to stop using a
122 superseded edition within 30 days of an update's release. MaxMind
123 updates GeoLite2 twice a week. A database 30 days past its build has
124 certainly been superseded, and counting from the build rather than
125 from the release of its successor errs on the strict side.
126 - **What happens:**
127 - At expiry every edge and the console drop the reader and delete
128 their local copies.
129 - The console deletes the blob and marks the pointer
130 `"expired": true` (§3).
131 - The deployment bucket is versioned, so a deleted blob survives as a
132 noncurrent version. A lifecycle rule on `control/geoip/` expires
133 noncurrent versions after 1 day (§15, codex R2 on R1-4).
134 - A `CONSOLE_GEOIP_DB` override file belongs to the operator. The
135 platform closes it at expiry and logs that it must be replaced, but
136 never deletes it.
137 - The box alert (§10.4) warns from day 21.
138 - **When it matters:** with the feed working, a GeoLite2 database never
139 gets near 30 days. Only a dead feed or a manual publish ever reaches
140 the limit.
141 - **DB-IP Lite has no such duty** (CC BY 4.0) and never expires. It
142 only alerts as stale.
143
144- **D2 (codex R6-1, reversing the R2-2 answer). Every request served over
145 the tunnel carries the hub's answer, TLS or not.**
146 - Round 2 withheld geo trust from plaintext tunnels, because nothing
147 authenticates the hub to the box there.
148 - That protects nothing. An on-path attacker who can pose as the hub on
149 a plaintext tunnel can already inject any request at all: method,
150 path, body, cookies, `X-Forwarded-For`. Geo headers add nothing to
151 what such an attacker controls.
152 - Withholding them did cost something. The hub's cache stored variants
153 under a country the app never received, and those variants were
154 replayed after the box reconnected over TLS. The hub's analytics
155 counted a country that §9's plaintext rule said shouldn't exist.
156 - So one mark, set on every request `tunnelclient` serves, means "the
157 hub computed this request's answer". The remote adopts that answer
158 and doesn't observe the request for analytics.
159 - Plaintext tunnels remain what `tunnelclient.Dial` already calls them:
160 "for tests". The operator docs say production tunnels use `https://`
161 (`infra/remote-host.md` already does).
162 - Because the hub's answer always reaches the app, the variant the hub
163 caches always matches what the app saw, whatever the transport, on
164 reconnect and on fleet fallback.
165
166- **R7, R8 (Paul, 2026-10-07):** D1 and D2 above are accepted as they
167 stand.
168- **R9 (Paul, 2026-10-07): free by default, paid by choice.**
169 - The documented default feed is `dbip-city-lite`. DB-IP Lite is free,
170 CC BY 4.0, and allows commercial use with the attribution the
171 platform already shows.
172 - Operators, the flagship included, may upgrade to MaxMind's paid
173 databases with `geoip2-city` or `geoip2-country`. These use the same
174 download API and credentials as GeoLite2.
175 - Paid editions are not GeoLite2, so D1's expiry does not apply, and
176 the platform shows no attribution for them: the operator's MaxMind
177 licence governs.
178 - The feed stays off until `CONSOLE_GEOIP_FEED` is set, so no
179 deployment starts downloading, or takes on a licence, without its
180 operator choosing to.
181 - GeoLite2 remains supported, but the operator docs note that MaxMind
182 calls it not recommended for commercial use and that its EULA limits
183 redistribution, which serving answers to customer apps may engage.
184
185## 1. Why, and what this is not
186
187The nearest-region work gave the console a GeoIP reader (`internal/geo`,
188`CONSOLE_GEOIP_DB`). It serves one caller: guessing a new app's region.
189Once the platform carries the database, every app should be able to ask
190"roughly where is this visitor" without shipping its own `.mmdb`, handling
191attribution, or tracking updates. Appendix A quotes the request.
192
193Goals:
194
195- Every proxied request to an app carries the visitor's country, and where
196 the database has them, subdivision and coordinates.
197- Apps can look up an arbitrary IP (batch jobs, logs, webhooks).
198- The platform's own analytics fill the `countries` map the analytics spec
199 reserved for this (`2026-09-07-errors-and-analytics-design.md`).
200- The operator publishes or configures a feed once per deployment, not once
201 per box.
202
203Not goals:
204
205- **Accuracy beyond the database.** The headers say what the database says.
206- **Header injection for traffic that never crosses the edge.** Scheduler
207 calls go straight to the socket. Sidecars reach their instance through
208 loopback.
209- **ASN, ISP, timezone, city-name or accuracy-radius headers.** Additive
210 later if asked for.
211- **A second reader.** Everything builds on `internal/geo`.
212
213## 2. Overview
214
215```
216operator ──carlos system geoip publish──┐
217 ▼
218feed (console, daily) ──────────► console publish path (one at a time)
219 │ verify, upload blob, flip pointer
220 ▼
221 bucket: control/geoip/current.json → control/geoip/<sha256>.mmdb
222 │
223 every mirroring edge polls the pointer (10 min)
224 ▼
225 /data/carlos/geoip/<sha256>.mmdb + current → geo.Swappable (hot swap)
226 │
227 ingress edge: strip X-Carlos-Geo-*, look up the visitor, keep the result
228 on the request; downstream edges (verified hop, tunnel)
229 adopt the validated headers instead of looking up
230 │ │
231 ▼ ▼
232 every proxy's Rewrite sets the headers analytics Obs.Country
233 on the outbound request → Day.Countries
234console: reads the same mirrored file → GET /api/cli/apps/{a}/{app}/geo?ip=
235 carlos geo <ip>
236```
237
238## 3. Bucket records
239
240The console is the **only writer** of `control/geoip/`. Edges only read it.
241A deployment runs one console, and inside it a single mutex serializes
242every publish, `off` and expiry through to the end of its cleanup
243(codex R1-2).
244
245**The blob** is `control/geoip/<sha256>.mmdb`. Its name is the hex SHA-256
246of its bytes, so a blob never changes after it is written.
247
248**The pointer** is `control/geoip/current.json`:
249
250```json
251{
252 "v": 1,
253 "sha256": "9f2c…",
254 "key": "control/geoip/9f2c….mmdb",
255 "size": 131072000,
256 "database_type": "DBIP-City-Lite",
257 "build_epoch": 1790812800,
258 "node_count": 7342011,
259 "published": "2026-10-06T21:00:00Z",
260 "origin": "feed:dbip-city-lite",
261 "edition": "2026-10"
262}
263```
264
265- `database_type`, `build_epoch` and `node_count` come from the file's own
266 MMDB metadata. `node_count` is recorded so the shrink check (§4.1) can
267 compare against the current pointer without reading the current blob
268 (codex R1-14).
269- `origin` is `manual` or `feed:<name>`. `edition` is the feed's own edition
270 marker: the `YYYY-MM` for DB-IP, the `Last-Modified` value for MaxMind,
271 and empty for a manual publish.
272- **No pointer means the feature is off.** An unknown `v` is treated as no
273 pointer, logged once.
274- **An expired pointer** (`"expired": true`, set only by D1) means "a
275 database was published and has run out". Edges load nothing, and the
276 `geoip` rule keeps firing (§10.4) until a fresh publish or an explicit
277 `off`. Expiry is not the same as off, so it never clears an alert
278 (codex R2-7).
279
280**Publishing,** holding the mutex:
281
2821. Read the pointer and its etag. Every comparison in §4.1 and §10.3 is
283 against this read.
2842. Upload the blob, create-only. If an object with that name already
285 exists, stream it and check its SHA-256 and size. Only if both match is
286 it the same bytes, and publishing carries on: publishing the same file
287 twice is not an error. Anything else is refused, and the previous
288 pointer and blob are left untouched (codex R8-3).
2893. Write the pointer with `ifMatch` set to the etag from step 1. A refusal
290 means something outside this console wrote the pointer. Stop, and leave
291 the new blob for the sweep.
2924. **Sweep:** delete every `control/geoip/*.mmdb` other than the one the new
293 pointer names. This removes the previous blob, the leftovers of any
294 earlier failure, and never the current one, even in an X to Y to X
295 sequence.
296
297`off` and expiry take the same mutex.
298
299- **`off`** deletes the pointer, then sweeps every blob.
300- **Expiry** rewrites the pointer with `"expired": true` (with `ifMatch`),
301 then sweeps every blob.
302
303`Store.Delete` has no condition. The pointer delete is therefore
304unconditional, and its safety rests on the single-writer rule above: one
305console, one mutex. Rev 3 drops the conditional delete that rev 2 asked
306for, rather than invent a store operation (codex R2-6).
307
308An edge that read the old pointer just before a publish can find its blob
309already deleted. It keeps what it has loaded and retries on the next poll,
310when it reads the new pointer.
311
312**The orphan sweep** (codex R3 on R1-4): a console that crashed after
313uploading a blob but before writing the pointer leaves a blob that is
314still a current version, which the noncurrent-version lifecycle rule
315(§15) doesn't touch. So the console runs the §3 sweep, holding the
316mutex, at startup and once a day. Deleting the orphan turns it into a
317noncurrent version, and the lifecycle rule then removes it.
318
319## 4. Publishing
320
321### 4.1 The console's publish path
322
323`geoipPublish(ctx, src io.Reader, origin, edition string, force bool)`, used
324by both the CLI verb and the feed:
325
3261. Refuse with 409 if another publish holds the mutex. Don't queue.
3272. Stream `src` to a fresh temporary directory under the console's data
328 directory, hashing as it goes.
329 - Refuse once 1 GiB has been read. The limit is enforced during the
330 copy, not afterwards (codex R1-7).
331 - The whole operation has a 15-minute deadline.
332 - The temporary directory is removed on every exit path.
3333. Open it with `geo.OpenMMDB`, which verifies it.
3344. **Country probe** (codex R1-21): look up `8.8.8.8`, `1.1.1.1`, `9.9.9.9`,
335 `208.67.222.222` and `2001:4860:4860::8888`. Refuse the database unless
336 at least three of them return a country. That catches an ASN database or
337 a schema the decoder can't read.
3385. **Expiry check** (codex R7-1): refuse a database that D1 already
339 counts as expired. `force` never overrides this. The check runs again
340 immediately before the pointer is written, because a long upload can
341 cross the expiry. A refusal leaves the previous pointer and blob
342 untouched.
3436. **Shrink check:** when the new database has the same `database_type`
344 as the current pointer's, refuse it if it has fewer than half that
345 pointer's `node_count`. This guards against a truncated file. The check
346 is skipped when the types differ, because node counts across products
347 aren't comparable. A manual publish can override the check with
348 `force`. The feed can't (codex R2 on R1-14).
3497. Publish (§3).
350
351Plan 1, task 1 measures `Verify`'s peak memory on a real DB-IP City Lite
352file and a real GeoLite2 City file, and records the result in the plan.
353`Verify` builds a map of data offsets, so it can cost much more than the
354file's size. If the peak would push the console box past its memory
355headroom, the plan says so and proposes a limit (codex R1-7).
356
357**Streaming upload** (codex R1-8). `Store.Put` takes the whole body as
358`[]byte`. Plan 1 adds one optional interface. Its body is an
359`io.ReadSeeker`, because the publish path always streams from its staged
360file, and the AWS SDK signs a seekable body without buffering it (codex
361R7-4):
362
363```go
364// release.PutStreamer is implemented by stores that can upload from a seekable reader.
365type PutStreamer interface {
366 PutFrom(ctx context.Context, key string, body io.ReadSeeker, size int64, ifMatch string) (etag string, err error)
367}
368```
369
370- It is implemented by `S3Store` (PutObject with a reader body and a
371 content length), `DirStore` (temp file and rename) and `prefixStore`
372 (rebase, then forward).
373- Plan 1 lists every other wrapper around the console's store and makes
374 each one forward `PutFrom` the way it forwards `Opener`.
375- A store without `PutFrom` takes the `Put` fallback only for files up to
376 64 MiB. Anything larger is refused with a clear error.
377
378**Streaming reads** use the existing `release.Opener` / `OpenObject`, which
379S3, `DirStore` and `prefixStore` already implement. No new read interface is
380added.
381
382### 4.2 Operator verbs
383
384Under `carlos system`, through the console API:
385
386- **Who may use them** (codex R1-13): a person, not a token, who
387 `apiSystemRoller` (`internal/console/apisystemupdate.go`) admits. That is
388 an owner of the ops account, or an address on `CARLOS_SYSTEM_OPERATORS`.
389 These are the people who can already roll the platform. The antivirus
390 doors use `apiSystemOperator`, which checks `CARLOS_STORE_OPERATORS`
391 instead, so they are not the precedent for this gate.
392- `carlos system geoip publish <file.mmdb> [-force]` streams the file to
393 the console, which runs §4.1.
394 - **Upload, then a job** (codex R4-2):
395 - The CLI sends a raw `application/octet-stream` body with a
396 `Content-Length`, through `PUT /api/cli/system/geoip`.
397 - The console's server-wide `ReadTimeout` is 5 minutes. So the handler
398 extends its own read deadline to 15 minutes with
399 `http.NewResponseController`, the way the ship door does
400 (`handleAPIShip`, survey), and wraps the body in `MaxBytesReader` at
401 1 GiB.
402 - **One slot, reserved first** (codex R5-1). The handler takes the
403 publish mutex (`TryLock`) **before reading any of the body**. If it
404 is held, by another upload still streaming or by a job, the answer
405 is 409 straight away. So at most one upload is ever staged on the
406 console's disk.
407 - The mutex and the temporary directory pass from the handler to
408 the job. The job releases the mutex only after removing the
409 directory.
410 - If the upload fails or is cut off before the job starts, the
411 handler removes the directory and releases the mutex itself.
412 - A test stalls a first upload's body and checks that a second
413 upload gets 409 without any of its body being read.
414 - The handler only stages the upload: it streams it to the publish
415 path's temporary file and hashes it.
416 - **The staging directory** is `<console data dir>/geoip-staging/`,
417 next to `CONSOLE_DB`, and used for nothing else. The console clears
418 it at startup, before it admits any upload or starts the feed, so a
419 crash during staging or during a job never leaves its 1 GiB behind
420 for good (codex R6-2). A test kills the publisher mid-stage and
421 mid-job, then checks that a fresh publisher on the same directory
422 starts empty and accepts a retry.
423 - It then answers **202 with a job id** straight away. A request that
424 came through another edge's backhaul has 30 seconds after its body
425 to see response headers (`backhaulHeaderTimeout`), and verifying
426 plus uploading can take longer.
427 - The job runs §4.1 steps 3 to 6 under the publish mutex and the
428 15-minute deadline.
429 - It reuses the console's existing system job registry
430 (`s.systemJobs`, polled at `GET /api/cli/system/jobs/{id}`, the door
431 `carlos system update` already uses).
432 - The CLI polls until the job is done and prints the result.
433 - A second upload while a job holds the mutex is answered 409 before
434 its body is read.
435 - A test publishes through a real backhaul, with the job delayed past
436 the header timeout.
437 - The existing `PUT /api/cli/system/upload` holds the whole body in
438 memory, so it isn't reused.
439- `carlos system geoip status` shows:
440 - the pointer (type, edition, build date, age, origin, expiry for
441 GeoLite2);
442 - the feed's configuration and its last result;
443 - the per-box report from §5.4.
444- `carlos system geoip off` deletes the pointer, then sweeps the blobs
445 (§3). While a publish holds the slot (an upload streaming in, or its
446 job), `off` answers **409 straight away** rather than waiting behind it
447 (codex R9-2). The operator runs it again once the publish finishes. Every edge drops its reader within one poll: at most 11.5 minutes,
448 with jitter and one bounded pointer read, while pointer reads succeed
449 (§5.3). The console follows its own box's edge within a further minute,
450 so it takes at most 12.5 minutes (§11.3, codex R2-9, R10). User-facing
451 copy rounds these up to 12 and 13 minutes. If
452 `CONSOLE_GEOIP_FEED` is set, `off` warns that the feed will publish
453 again within a day and says to unset it.
454
455## 5. The edge mirror
456
457### 5.1 Where and who
458
459- The mirror runs inside `carlos agent`'s edge when the edge has a store
460 (`e.staticStore`, set from `CARLOS_DEPLOYMENT_BUCKET` or
461 `CARLOS_DEPLOYMENT_DIR`) and no tunnel client is configured. Its mode is
462 `mirror` (survey).
463- **It does not run on boxes that use the tunnel client** (`CARLOS_TUNNEL_*`
464 set). Those boxes take the hub's headers (§8), and their mode is
465 `tunnel`.
466- **A box with no store and no tunnel** has mode `none`. It strips forged
467 headers, as every edge does, and sets none.
468- **Files** live under `/data/carlos/geoip/`, inside the edge unit's
469 `ReadWritePaths=/data`.
470 - Nothing creates `/data/carlos` today (survey). The mirror creates the
471 directory itself at mode 0755, and the files are 0644. The data isn't
472 secret.
473 - Bootstrap makes `/data` mode 0750, owned `carlos:carlos`. The console
474 already writes `/data/registry.db` and `/data/console/console.db`, so
475 whatever user its hand-installed unit runs as can already get into
476 `/data` (the unit isn't in the repo, survey). Step 2 of the rollout
477 (§15) checks on the console box that the console can read
478 `/data/carlos/geoip/current`.
479
480### 5.2 Local layout (codex R1-5)
481
482Each generation is immutable and named by its digest:
483
484```
485/data/carlos/geoip/<sha256>.mmdb one per generation, written once
486/data/carlos/geoip/current {"sha256": "...", "database_type": "...", "build_epoch": ...}
487/data/carlos/geoip/.incoming-* downloads in progress
488```
489
490`current` is the only file that changes. It is written to a temporary file,
491fsynced, renamed over `current`, and then the directory is fsynced. A
492reader that sees `current` naming a sha always finds that generation
493complete, because the generation was renamed into place before `current`
494was written. A crash at any step leaves either the old `current` with the
495old generation, or the new `current` with the new one.
496
497### 5.3 The loop
498
499- **At boot:**
500 1. Read `current` and open `<sha>.mmdb` with `geo.OpenMMDBTrusted`. That
501 opens the file without `Verify` (§6), because the console verified
502 those exact bytes and the name proves they are the same.
503 2. Delete any other `*.mmdb` and any `.incoming-*`.
504 3. Serving never waits on the bucket.
505 4. A `current` that names a missing or unopenable file is removed and
506 logged, and the edge starts with no database.
507 5. A GeoLite2 generation that has passed its expiry (D1) is deleted, not
508 opened.
509- **One lock for selection** (codex R3-1). Choosing the generation,
510 expiring it and cleaning up the local files all happen under one mutex,
511 `genMu`. That covers writing `current`, `Swap`, and deleting
512 generations. Network reads and downloads happen outside it, so a stuck
513 download never holds it.
514- **A finished download is installed only if the pointer still names it**
515 (codex R8-2).
516 - After the download the mirror re-reads the pointer. If the pointer now
517 names something else, is gone, or has expired, the download is
518 deleted and the pass re-runs once at once. So an `off` or a newer
519 publish that landed mid-download takes effect at the end of that pass.
520 - If the re-read fails, nothing is installed until the next pass.
521 - Each pass is due 10 minutes plus jitter after the previous one
522 **started**, and a pass is bounded at 10 minutes.
523 - Every pointer read has its own 30-second timeout (codex R9 on R8-2).
524 A change to the pointer is therefore acted on within 11 minutes plus
525 one pointer read (at most 11.5 minutes), even with a long download in
526 flight. That is the bound §4.2 quotes.
527 - While pointer reads keep failing, the edge keeps what it has loaded,
528 and its heartbeat reports the error, so the verdict shows the box as
529 not caught up.
530- **Every 10 minutes,** with up to 1 minute of jitter, read the pointer
531 (bounded read, at most 64 KiB). Each pass has a 10-minute deadline,
532 covering the pointer read and any blob download. A pass that misses it
533 is abandoned and its `.incoming-*` deleted (codex R2 on R1-7). The
534 GeoLite2 expiry check runs on its own one-minute ticker, with no network
535 access, so a stuck download can't delay it.
536- **An expired candidate is never installed.**
537 - A pointer whose `build_epoch` is past D1's expiry (it carries
538 `database_type` and `build_epoch`) is treated as an expired pointer
539 before anything is downloaded.
540 - The check runs again under `genMu`, immediately before installing,
541 against the downloaded file's own metadata. A download that crossed
542 its expiry is deleted, not installed.
543 - The mirror remembers the expired sha and doesn't fetch it again until
544 the pointer names another one. An expired pointer the console couldn't
545 mark can therefore never come back.
546 - **The pointer names the sha already loaded:** nothing.
547 - **It names a new sha:**
548 1. Stream the blob through `OpenObject` into `.incoming-<sha>`. Stop and
549 delete it if it exceeds the pointer's `size` or 1 GiB, or if the disk
550 write fails.
551 2. Check the digest.
552 3. Fsync and rename it to `<sha>.mmdb`.
553 4. Write `current` (§5.2), open the generation and swap the reader
554 (§5.4).
555 5. Delete the previous generation.
556 - **No pointer, or an expired one:** write the empty state to
557 `current`, swap in a nil reader, then delete the generations.
558 - **The read fails** (network, permissions): keep what is loaded, and log
559 once an hour while it keeps failing.
560- **A blob whose digest doesn't match** is deleted, logged once per sha,
561 and not retried until the pointer changes.
562- **GeoLite2 expiry (D1):** the expiry ticker takes `genMu`, then checks
563 the **currently loaded** generation's `build_epoch`. Past expiry it swaps
564 in nil and deletes that generation, whatever the pointer says.
565 - Because it checks after taking the lock, it acts only on the
566 generation that is current at that moment. It can never inspect an
567 expired A and then remove a freshly installed B.
568 - Tests:
569 - a download crossing expiry;
570 - repeated polls against an expired pointer that was never marked;
571 - an install racing the ticker.
572
573### 5.4 Hot swap
574
575`geo.Swappable` wraps the reader:
576
577```go
578type Swappable struct {
579 mu sync.RWMutex
580 cur *MMDB
581}
582// Snapshot is one answer from one reader: the result, that reader's
583// source, and whether any reader was loaded at all (codex R2-4).
584type Snapshot struct {
585 Result Result
586 Found bool
587 Source string // empty only when Loaded is false
588 Loaded bool
589}
590func (s *Swappable) Lookup(ip net.IP) Snapshot // one RLock covers lookup and source
591func (s *Swappable) Locate(ip net.IP) (Point, bool) // geo.Locator; RLock held through MMDB.Locate; a miss when empty
592func (s *Swappable) Swap(next *MMDB) // Lock, replace, Unlock, then Close the old one
593```
594
595The headers, analytics and the endpoint's attribution all come from one
596`Snapshot`, so a swap between the lookup and the response can't label
597DB-IP data as GeoLite2. There is deliberately no separate `Source()` on
598`Swappable`.
599
600`Swappable` implements `geo.Locator`, so the console's `Config.GeoIP` holds
601the `Swappable` itself (codex R3-2). Region guesses
602(`internal/console/regionguess.go`) never see the `*MMDB` underneath and
603can't outlive a swap. The `-race` test runs concurrent `Locate` calls as
604well as `Lookup` calls.
605
606- The old mapping is closed only after the write lock has been taken and
607 released. No lookup can be in flight on an unmapped file.
608- A lookup holds the read lock for a few microseconds, so a swap waits that
609 long at most.
610- Go's `RWMutex` blocks new readers while a writer waits, so a steady
611 stream of lookups can't starve the swap.
612- The swap gets a `-race` test with concurrent lookups (§14).
613
614### 5.5 Reporting (codex R1-16)
615
616Each box's status heartbeat gains a block on **every** box running a
617binary with this feature: a new `GeoIP` field in `status.BoxMetrics`
618(`internal/status/snapshot.go`), which `BuildFleet` never copies into the
619public `fleet.json`. Platform boxes write `status/boxes/<label>.json`, and
620fleet boxes write under `fleets/<fleet>/status/boxes/`:
621
622```json
623"geoip": {"mode": "mirror", "sha256": "9f2c…", "type": "DBIP-City-Lite", "error": ""}
624"geoip": {"mode": "tunnel"}
625"geoip": {"mode": "none"}
626```
627
628`carlos system geoip status` reconciles two lists (codex R4 on R1-16).
629
630**Box identity is (namespace, label)** (codex R8-1). The namespace is the
631platform (`""`) or a fleet's name. Fleet labels repeat across fleets and
632can equal a platform label, so every match, every expectation and every
633blocking line uses the pair. Lines name a fleet box as `<fleet>/<label>`.
634
635**What it reads:**
636- **Expected boxes:**
637 - every platform box that publishes an edges record
638 (`control/edges/<box>.json`, what the home map reads);
639 - every active fleet box the registry lists (`ListFleetBoxes`). Fleet
640 boxes don't publish edges records (`internal/host/edgesfile.go`), so
641 they are reconciled against the registry instead (codex R5 on R1-16).
642- **Heartbeats:** every prefix (the platform's and each fleet's
643 `fleets/<name>/status/boxes/`) is read with `status.LoadBoxesComplete`,
644 which says when any record couldn't be read. Neither `LoadBoxes` nor
645 `systemBoxes`'s strict mode is used, because both skip unreadable
646 records silently.
647
648**What the pointer asks of every mirror** (codex R9-1):
649
650| Pointer | Each expected mirroring box must report |
651|---|---|
652| live | the pointer's sha |
653| absent (off), or expired (marked, or past D1 by the clock, codex R10) | no generation loaded |
654| unreadable | nothing satisfies this: the verdict is never ready while the pointer can't be read |
655
656**Which boxes are listed, and which can block:**
657- Every expected box is listed whatever its age, and **only expected
658 boxes can block** the verdict (codex R8 on R1-16).
659- A box with a heartbeat but no edges record or fleet registration is
660 listed, for information, unless its last heartbeat is more than 7 days
661 old. It never blocks. So deleting a retired box's edges record (or
662 detaching a fleet box) unblocks the verdict at once.
663
664It reports:
665
666- **stale,** by the shared rule `status.BoxStale` (3 heartbeat periods,
667 15 minutes), not a threshold of its own (codex R3-4). The verdict can't
668 vouch for a box it can't currently see.
669- **record unreadable:** the box is missing from what was read, so if it
670 is expected it blocks as "no heartbeat". An unreadable record for a box
671 nobody expects blocks nothing (codex R9 on R1-16). If the list of
672 expected fleet boxes itself can't be read, that always blocks.
673- **expected box with no heartbeat at all:** "no heartbeat".
674- **no block:** "old binary". The headers can't be trusted while any such
675 box is serving.
676- **`mode: tunnel`:** takes the hub's headers.
677- **`mode: none`:** no store and no tunnel, so no headers. Safe, because
678 it strips forged ones.
679- **`mode: mirror`:** shows the box's sha against the pointer's.
680
681**Retiring a box** (codex R6 on R1-16). Nothing deletes a retired box's
682edges record today (`internal/console/instancesplacement.go`, "nothing
683deletes a retired box's file"). A stale heartbeat is never taken as
684retirement.
685- An expected box whose heartbeat is stale blocks the verdict, with the
686 instruction: "no heartbeat since <date>. if this box is retired, delete
687 its edges record (control/edges/<label>.json)".
688- Deleting that record is the explicit retirement. It also stops the home
689 map backhauling traffic to a dead box.
690- A `carlos system` verb to retire a box belongs to the platform, not to
691 GeoIP. Plan 1 files it as a discussion rather than working around its
692 absence.
693
694It ends with a one-line verdict. **"Ready"** means the pointer was read,
695the expected-box list was complete, and every **expected** box is fresh,
696readable and GeoIP-aware, with every expected mirroring box holding what
697the pointer asks for (table above). Anything else names the boxes in the way (codex R2 on R1-16).
698A test sets a box's heartbeat just inside the next jittered tick and
699checks it still reads as fresh.
700
701## 6. `internal/geo` changes
702
703`Locate` keeps its exact behaviour (codex R1-9). Both methods share one
704decode and validation step:
705
706```go
707type Result struct {
708 Country string // ISO 3166-1 alpha-2; country, else registered country
709 Subdivision string // ISO 3166-2 like "GB-SCT"; empty unless the database has an ISO code
710 Point Point
711 HasPoint bool // true only for a real location record, never a centroid
712}
713func (m *MMDB) Lookup(ip net.IP) (Result, bool)
714func (m *MMDB) Source() string // metadata.DatabaseType, reduced to [A-Za-z0-9._-], at most 64 bytes; "mmdb" if that leaves nothing
715func (m *MMDB) BuildEpoch() time.Time // UTC; the pointer stores BuildEpoch().Unix()
716func (m *MMDB) NodeCount() uint64
717func OpenMMDBTrusted(path string) (*MMDB, error) // opens without Verify
718```
719
720- **The source is never empty** (codex R7-2). A database type that
721 sanitises to nothing becomes `mmdb`, which satisfies the header grammar
722 (§8.4). A loaded reader therefore always has a source that crosses a
723 hop or the tunnel intact, and a local hit is never turned into `off`
724 downstream.
725- **Shared:** `decode(ip) (mmdbRecord, bool)` applies the `Locatable`
726 filter, `To4` normalisation and the fixed-category error log, and treats
727 a malformed location as a miss of the whole record.
728- **`Locate`:** unchanged. A valid point succeeds even without a country.
729 Otherwise it tries the country's representative point, then the
730 registered country's. Today's tests stay, and plan 1 adds regression
731 cases for "point with no country" and "country with no representative
732 point but a registered country that has one".
733- **`Lookup`:** succeeds when it finds a country, or a point, or both.
734 - `Country` is the country's ISO code, else the registered country's.
735 - `Subdivision` is `country + "-" + subdivisions[0].iso_code`, only when
736 both exist and the code is 1 to 3 `[A-Z0-9]` characters.
737 - **DB-IP Lite has subdivision names but no ISO codes**, so with DB-IP
738 the subdivision is always empty. With GeoLite2 City it is usually
739 filled.
740
741## 7. The header contract
742
743| Header | Value | Present when |
744|---|---|---|
745| `X-Carlos-Geo-Country` | ISO 3166-1 alpha-2, upper case | a country was found |
746| `X-Carlos-Geo-Subdivision` | ISO 3166-2, e.g. `GB-SCT` | the database has an ISO subdivision code (GeoLite2, not DB-IP Lite) |
747| `X-Carlos-Geo-Lat` | decimal degrees, 2 places | the record has a real location |
748| `X-Carlos-Geo-Lon` | decimal degrees, 2 places | the same |
749| `X-Carlos-Geo-Source` | the database type, e.g. `DBIP-City-Lite`, `GeoLite2-City` | any geo header is present |
750
751- **All five are absent** on a miss, on an address that isn't locatable,
752 when no database is loaded, and on a door request (§8). The platform
753 never guesses.
754- **Coordinates are an approximate area, not a position** (codex R1-20).
755 The two decimal places are formatting only. The database's own accuracy
756 can be tens or hundreds of kilometres, and the app docs say so.
757- Apps can already see the client IP in `X-Forwarded-For`, so these headers
758 tell an app nothing it couldn't work out for itself.
759- **Caching** (codex R1-19). An app that varies a cached response on a geo
760 header must send `Vary` on it. The edge cache keeps at most 8 variants
761 per URL across every `Vary` combination
762 (`internal/edge/cache.go`). A ninth country evicts one, and other
763 `Vary` headers multiply the combinations. The app docs say plainly that
764 varying on country means more cache misses for a page with a worldwide
765 audience. Varying on coordinates makes the cache almost useless for that
766 page.
767
768## 8. Trust: who may set the headers
769
770### 8.1 Order of operations (codex R2-3)
771
7721. **Capture and strip,** at the top of `ServeHTTP`, before any branch. The
773 edge copies `X-Carlos-Hop-Geo` and every `X-Carlos-Geo-*` header into an
774 untrusted `capturedGeo` value, then deletes them all from `r.Header`.
775 Nothing reads `capturedGeo` yet.
7762. **Authenticate:** `acceptHop` runs where it does today, and so does the
777 tunnel mark, which comes from the handler wrapper.
7783. **Decide,** after `acceptHop` and before either backhaul branch, the
779 door, the gate, analytics, the upgrade branch and the cache lookup. The
780 edge sets `geoResult` once, from the table in §8.2. Captured values are
781 adopted only when step 2 trusted the transport. Otherwise they are
782 discarded.
7834. **Expose:** the edge writes the app-visible headers from `geoResult`
784 back onto `r.Header`, so `Vary` matching sees exactly the values the app
785 will get. `X-Carlos-Hop-Geo` is never written back to `r.Header`.
786
787### 8.1a The internal trust header (codex R2-5)
788
789Without more information a downstream edge can't tell "the ingress looked
790up and missed" from "the ingress had no database" or "this was a door
791call", since all three send no app headers. So the edge that forwards over
792a backhaul or the tunnel adds one internal header:
793
794```
795X-Carlos-Hop-Geo: hit;src=DBIP-City-Lite
796X-Carlos-Hop-Geo: miss;src=DBIP-City-Lite
797X-Carlos-Hop-Geo: off
798```
799
800- `hit` and `miss` mean a reader was loaded and answered. `off` means no
801 lookup was attempted: no database, a door request, or a rejected
802 hop.
803- It is set only by `backhaulProxy`, `tunnelProxy` and `fleetProxy`, and
804 only from `geoResult`.
805- It is stripped at capture (step 1) on every request, and adopted only
806 under the same trust as the app headers.
807- `setGeo` deletes it from every outbound request to an app, so it never
808 reaches app code.
809- **An adopted `hit` needs a valid country or a valid coordinate pair**
810 (codex R3-3), matching `Lookup`'s own success rule (§6). A `hit` with
811 neither is treated as `off`, and so is a missing or malformed
812 `X-Carlos-Hop-Geo` on a trusted transport. Fewer countries are counted,
813 but none is made up.
814- A test checks that identical app headers and analytics come out of a
815 local route, a backhaul and the tunnel, including for a coordinate-only
816 result.
817
818### 8.2 Computing: only at ingress
819
820The **ingress edge** is the one that accepted the visitor's connection.
821Only it looks the visitor up, using `clientIP(r)`. Every other edge adopts
822the validated headers it received.
823
824| Request | Geo result |
825|---|---|
826| Direct request | Look up `RemoteAddr`: `hit` or `miss`, or `off` with no database |
827| Door request (edge-local socket) | **`off`.** The door replaces `RemoteAddr` with the box's own public address, so a lookup would locate the box. |
828| Sidecar through loopback | `miss`, because the address isn't locatable |
829| Verified backhaul hop (home edge) | Adopt the sending edge's validated headers and state. No lookup. |
830| Request over the tunnel (remote box), TLS or not | Adopt the hub's validated headers and state. No lookup (D2). |
831| **Rejected hop:** it carries `X-Carlos-Hop`, but `acceptHop` didn't verify it | **`off`** (codex R2-1). The request is served as today, as an ordinary client with its forwarded headers stripped, but geo is withheld, because its `RemoteAddr` is the sending edge. A client that sends a bogus `X-Carlos-Hop` only suppresses its own geo. |
832| Any other request | The same as a direct request |
833
834- **The sending edge** of a backhaul computes the result like any ingress
835 edge, then forwards it. A door request that is backhauled therefore
836 carries no geo headers. The home edge adopts that absence and never
837 locates the sending box (codex R1-3).
838- **The result goes on the request context** as `geoResult`. Analytics
839 (§9) reads it from there, so an adopted result counts the same as a
840 computed one (codex R1-10).
841
842### 8.3 Setting them on the outbound request
843
844Every proxy's `Rewrite` (unix, tcp, multi, tunnel, fleet, backhaul) calls
845one helper, `setGeo(pr)`, after `setForwarded(pr)`. It:
846
847- deletes every `X-Carlos-Geo-*` header and `X-Carlos-Hop-Geo` from
848 `pr.Out`;
849- writes the app headers back from the context's `geoResult`;
850- writes `X-Carlos-Hop-Geo` too, but only when the proxy forwards to
851 another edge (`backhaulProxy`, `tunnelProxy`, `fleetProxy`, §8.1a).
852
853`ReverseProxy` removes headers named in `Connection` before calling
854`Rewrite`. So a client sending `Connection: X-Carlos-Geo-Country` can no
855longer strip the country from the app's request while the cache files the
856response under that country (codex R1-1). The cache stores the response
857under the same values `setGeo` writes, because both come from `geoResult`.
858
859### 8.4 Who is trusted, and how much
860
861- **Verified hop:** `hopVerifiedKey`, which only `acceptHop` sets, after an
862 HMAC check against the mesh key.
863- **Tunnel:** a new context mark, `tunnelKey{}`.
864 - `tunnelclient` wraps the handler it gives `http2.Server.ServeConn`, so
865 every request it serves carries the mark, and nothing else sets it.
866 - The mark is set whatever the tunnel's scheme (D2).
867 - The same mark tells the remote edge not to observe the request for
868 analytics (§9).
869 - Tunnel boxes do run the ordinary edge listeners on :80 and :443
870 alongside the tunnel (`internal/host/agent.go`, `tunnelLoop`). A
871 request through those listeners doesn't carry the mark, so it is
872 stripped. A tunnel box holds no database, so it gets no headers either
873 (codex R1-17).
874- **Validation before adopting a header** (anything invalid is dropped on
875 its own):
876 - country `^[A-Z]{2}$`;
877 - subdivision `^[A-Z]{2}-[A-Z0-9]{1,3}$`, whose prefix matches the
878 country;
879 - lat/lon decimal numbers in range, re-rounded to 2 places, and kept
880 only as a pair;
881 - source `^[A-Za-z0-9._-]{1,64}$`, kept only if some other field
882 survived.
883- **Tests:**
884 - a forged header on every proxy type, with and without a `Connection`
885 nomination, including a cache replay;
886 - a forged header on a tunnel box's public listener;
887 - a request through a real `tunnelclient` connection that adopts the
888 hub's headers. The remote box makes **zero** analytics observations,
889 and the hub exactly one (codex R5 on R4-1, §9);
890 - a door call to an app whose home is another box, which reaches the app
891 with no geo headers;
892 - a hop signed with a key the verifier doesn't have yet (the rotation
893 window), and a door call backhauled in that window. Both are served,
894 with no geo;
895 - miss, off and door states crossing both a backhaul and the tunnel, and
896 counted correctly at the far side.
897
898### 8.5 Mixed versions during a roll
899
900An old edge doesn't strip `X-Carlos-Geo-*`, so an old sending edge passes a
901forged header across a verified hop, and a new home edge adopts it. The
902contract therefore holds only once every edge runs a GeoIP-aware binary.
903The rollout (§15) uses `carlos system geoip status`'s verdict (§5.5) to
904show that, and only then publishes a database and the app docs.
905
906## 9. Analytics countries (codex R1-11, R1-12)
907
908- **Counting unit:** pageviews. A pageview counts toward `Countries` when
909 its `geoResult` state is `hit` or `miss`.
910- `analytics.Obs` gains `Country`, `GeoSource` and `GeoState`, which
911 `observeAnalytics` reads from the context's `geoResult`. It never looks
912 up again.
913- **By state:**
914 - `hit` counts the country, or `"_unknown"` for a coordinate-only result
915 (codex R3-3).
916 - `miss` counts `"_unknown"`.
917 - `off` (no database, a door call, a rejected hop) isn't counted in `Countries` at all, so "no GeoIP" never looks like
918 "visitors from nowhere".
919- The state crosses hops and the tunnel in `X-Carlos-Hop-Geo` (§8.1a), so
920 the edge that observes the pageview counts it the way the ingress edge
921 saw it.
922- **One observation per served response** (codex R4-1).
923 - Today a request to a tunnel or fleet box is observed twice: once by
924 the hub's observer, which runs for every proxied route, and once by
925 the remote edge. Both flush to the same unprefixed
926 `apps/<sqid>/<app>/analytics/`. That already double-counts pageviews,
927 and countries would inherit the same double count.
928 - **Rule:** the remote edge doesn't observe a request that arrived over
929 the tunnel. The hub has already observed it, including when the hub
930 served it from its cache, where the remote never sees it.
931 - **Mark:** the tunnel mark, `tunnelKey`, which `tunnelclient` puts on
932 every request it serves (§8.4, D2).
933 - **Scope:** usage accounting (`usageMeter`) is a separate meter, and
934 this doesn't change it.
935 - **Test:** a real hub and remote edge with both meters on, both
936 flushed. Pageviews, countries and sources each go up by exactly one,
937 for a fresh request and for a hub cache hit.
938- The sending edge of a backhaul does no metering today (§8.2), so a
939 backhauled request is already observed once, at the home edge.
940- `Delta`, the flush and `Day` gain `GeoSources map[string]int64`
941 (`json:"geo_sources"`): pageviews counted per database type. This is the
942 provenance the attribution needs. A day's data can come from two sources
943 if a publish changed the type mid-day.
944- **Merging:**
945 - `Day.Countries` and `Day.GeoSources` merge additively across flushes;
946 - the read path (`ReadRange`, `Summary`) sums them across boxes and
947 days;
948 - the analytics API response carries `countries` (top 10 plus `_other`)
949 and `geo_sources`.
950 - Both maps are bounded: countries by the ISO list plus `_unknown` and
951 `_other`, sources at 8 entries plus `_other`.
952 - **A source with a known attribution** (§13: the `DBIP-` and
953 `GeoLite2-` prefixes) is never folded into `_other`, whatever the cap.
954 Only unattributed types fold (codex R7-3). Those prefixes cover a
955 handful of product names, so the map stays small, and every credit a
956 retained range owes stays readable.
957- **Old and new days:** days written before plan 1 have no `geo_sources`
958 and an empty `countries`. They contribute nothing, and the read path
959 treats the missing field as empty. A mixed range tests this.
960- **The console's analytics page** (`app_analytics.html`) gains a Countries
961 table: the top 10, then "other". With no country data in the range it
962 says so instead.
963- **Attribution:** the page shows one attribution line for every source in
964 the range's `geo_sources`, using the table in §13. Historical DB-IP
965 counts keep their DB-IP credit after a switch to GeoLite2, and the credit
966 stays after `off`.
967- The new strings go through copy-review before they are written.
968
969## 10. The feed (plan 2)
970
971The console fetches new editions itself. It is the one control-plane
972process per deployment, so there is only ever one fetcher.
973
974### 10.1 Configuration (console env)
975
976| Variable | Meaning |
977|---|---|
978| `CONSOLE_GEOIP_FEED` | `dbip-city-lite` (the documented default, R9), `dbip-country-lite`, `geolite2-city`, `geolite2-country`, and the paid `geoip2-city` and `geoip2-country`; unset means no feed |
979| `CONSOLE_GEOIP_MAXMIND_ACCOUNT` | MaxMind account ID, for `geolite2-*` and `geoip2-*` |
980| `CONSOLE_GEOIP_MAXMIND_KEY` | MaxMind license key, for `geolite2-*` and `geoip2-*` |
981
982- An unknown feed name, or a GeoLite2 feed with no credentials, is logged
983 once at startup. The feed stays off and the console still starts.
984- The key is never logged and never sent anywhere but MaxMind's download
985 host. It goes in Basic auth, never in the URL.
986
987### 10.2 Checking for a new edition
988
989The console checks once a day, at a jittered time, and once 5 minutes after
990it starts.
991
992- **DB-IP:** the edition is `YYYY-MM`, from
993 `https://download.db-ip.com/free/dbip-<city|country>-lite-YYYY-MM.mmdb.gz`.
994 - If the pointer's `edition` isn't this month, it tries this month's URL.
995 A 404 means it isn't out yet, so it tries again tomorrow.
996 - With no pointer, or a pointer from another origin, it tries this month
997 and then last month.
998- **MaxMind:** it HEADs
999 `https://download.maxmind.com/geoip/databases/GeoLite2-<City|Country>/download?suffix=tar.gz`
1000 with Basic auth and compares `Last-Modified` with the pointer's
1001 `edition`. MaxMind documents HEAD as not counting against the download
1002 limit. When the edition differs, it fetches `suffix=tar.gz.sha256` and
1003 then the archive.
1004- **Redirects:** both providers redirect downloads (MaxMind to R2 presigned
1005 URLs). The client follows at most 3 redirects, HTTPS only. On a
1006 cross-host redirect it drops the Basic auth header.
1007- A URL that starts answering 404 or 410 for more than 7 consecutive daily
1008 checks raises the box alert's feed reason (§10.4). That is how a provider
1009 changing its URLs shows up.
1010
1011### 10.3 Integrity and safety
1012
1013- **Bounded input** (codex R1-7). The download streams to the publish
1014 path's temporary directory.
1015 - **The compressed download** is capped at 512 MiB while it is being
1016 copied.
1017 - **Decompression:** `.gz` for DB-IP. For MaxMind, the single regular
1018 `.mmdb` member of the `.tar.gz`. Every byte the tar reader expands
1019 counts toward a 1.5 GiB cap, including members it skips, and the
1020 `.mmdb` itself is capped at 1 GiB.
1021 - Each check runs under the 15-minute deadline. The directory is removed
1022 on every exit path.
1023- **Checksums** (codex R1-18). MaxMind's archive is checked against its
1024 published `.sha256`. DB-IP shows MD5 and SHA-1 checksums on its HTML
1025 download page but offers no machine-readable checksum endpoint. The feed
1026 doesn't scrape the page. It relies on TLS to `download.db-ip.com`, then
1027 `Verify`, the country probe and the shrink check (§4.1).
1028- **Type check** (codex R1-14). The database's `database_type` must match
1029 the configured feed, every time, including the first publish:
1030 - `dbip-city-lite` → `DBIP-City-Lite`
1031 - `dbip-country-lite` → `DBIP-Country-Lite`
1032 - `geolite2-city` → `GeoLite2-City`
1033 - `geolite2-country` → `GeoLite2-Country`
1034- **Monotonic.** When the current pointer came from the same feed, the new
1035 `build_epoch` must be newer. A pointer from a manual publish or another
1036 feed is replaced by any database that passes the type check, and the
1037 feed logs that it replaced it. The feed never uses `force`, so the
1038 shrink check applies whenever the pointer came from a feed with the same
1039 type.
1040- **First publish:** if the pointer is absent and the feed is set, the
1041 feed publishes. That is how a fresh deployment gets its first database.
1042- **On any failure** the current database stays, and each edition's failure
1043 is logged once.
1044- **GeoLite2 freshness:** MaxMind updates twice a week and the feed checks
1045 daily, so a working feed keeps the database well inside D1's 30 days.
1046
1047### 10.4 Alerts (codex R1-15)
1048
1049Today the console's alert watcher only reads per-box records under
1050`alerts/boxes/`, and webhooks are delivered by the host process. A
1051deployment-level console alert would need a new pipeline. Instead, GeoIP
1052becomes a **box rule**, `geoip`, alongside `disk`, `mem`, `cpu` and `lag`.
1053
1054- **Evaluation:** each mirroring edge evaluates it on its own state. The
1055 rule fires once any of these has held for 30 minutes, using
1056 `metrics.RuleState`'s existing sustain:
1057 - the loaded database is older than 45 days, for any type;
1058 - a GeoLite2 database is within 9 days of its D1 expiry (21 days old);
1059 - the pointer is expired: marked (§3), **or past D1 by the clock**. The
1060 mirror keeps the observed pointer's type and build after it drops the
1061 reader, so expiry never clears the alert before the console's daily
1062 pass marks the bucket (codex R11-1);
1063 - a live pointer exists but this box hasn't loaded it for over an hour
1064 (a bad blob or a full disk);
1065 - **the feed reason** (codex R2-7), evaluated only by the console box's
1066 edge, so it fires once:
1067 - the console's feed result, published at `control/geoip/feed.json`,
1068 shows 7 consecutive failed checks. This reason is independent of
1069 the pointer, so a fresh deployment whose feed has never succeeded
1070 still alerts.
1071 - `feed.json` is written by the console after every check (configured
1072 feed, last success, consecutive failures, last failure category). It
1073 is deleted when the feed is unset.
1074- **Delivery:** through the existing paths. The host's alert sink handles
1075 the webhook (when `CARLOS_ALERT_WEBHOOK` is set, which on the flagship it
1076 isn't yet, #192) and the console's alert watcher sends email.
1077 `internal/host/alertsink.go` gains the `geoip` cases for its message
1078 text.
1079- **Clearing is immediate** (codex R2-8): the first evaluation in which no
1080 reason holds clears the rule. That matches what the existing machinery
1081 does (`internal/metrics/alert.go`, which clears on the first reading
1082 below the line). None of these conditions flaps, so no sustained-clear
1083 bookkeeping is added.
1084 - The pointer-related reasons clear on a fresh publish, or on an
1085 explicit `off`, which deletes the pointer.
1086 - The feed reason clears on the feed's next success, or when the feed
1087 is unset.
1088 - Expiry alone never clears anything.
1089- **Tests:**
1090 - it fires with no other box alert present;
1091 - it clears after a publish;
1092 - it clears after `off`;
1093 - it stays firing through expiry;
1094 - the feed reason fires with no pointer at all;
1095 - it restarts with the rule firing.
1096
1097## 11. The lookup endpoint and CLI (plan 3)
1098
1099### 11.1 Endpoint
1100
1101`GET /api/cli/apps/{account}/{app}/geo?ip=<addr>`, registered through
1102`cliRoute`:
1103
1104- **Allowed:** the instance token (`svcrole.Instance`), and the member and
1105 app tokens that can read the app's analytics.
1106- **Rate limit:** the existing per-caller limiter at 60 a minute. The
1107 intended use is occasional lookups, not bulk enrichment; a bulk form can
1108 come later if anyone needs it.
1109- **200** `{"found": true, "country": "IE", "lat": 53.34, "lon": -6.27, "source": "DBIP-City-Lite", "attribution": "IP Geolocation by DB-IP", "attribution_url": "https://db-ip.com"}`.
1110 - The fields follow §7: absent values are omitted, and the coordinates
1111 are rounded the same way.
1112 - The source and attribution come from the same `Snapshot` as the
1113 result (§5.4), so they always describe the reader that answered,
1114 which may be the `CONSOLE_GEOIP_DB` override.
1115- **200** `{"found": false}` for an address that isn't locatable or isn't
1116 in the database.
1117- **400** for a missing or malformed `ip`.
1118- **503** `geo unavailable` when no database is loaded.
1119- The console never logs the queried IP.
1120
1121### 11.2 CLI
1122
1123`carlos geo <ip> [--app <app>]` calls the endpoint and prints the fields
1124and the attribution. `--json` prints the response as is. With no app
1125context it uses the current app, like the other app verbs.
1126
1127### 11.3 The console's reader (codex R1-6)
1128
1129The console reads the generation that the console box's own edge mirrors.
1130Once a minute it reads `/data/carlos/geoip/current` (§5.2):
1131
1132| What it finds | What it does |
1133|---|---|
1134| a new sha | open that generation with `OpenMMDBTrusted` and swap |
1135| the same sha | nothing |
1136| `current` missing, or naming no database | `Swap(nil)`. The console therefore follows `off` within one edge poll plus one console poll (§4.2). |
1137| any other read error | keep what's loaded, log once an hour |
1138
1139- **Starting with no file** is the normal case before the first publish.
1140 The reader starts empty and picks up the first generation within a
1141 minute.
1142- **GeoLite2 expiry (D1)** is checked on the same tick, independently of
1143 the edge.
1144- **Region guesses and the endpoint** share that one reader.
1145- **`CONSOLE_GEOIP_DB`**, when set, overrides the mirrored path and keeps
1146 its current rules: it's opened once at start, with `Verify`, and
1147 replacing it means a rename plus a restart. Existing deployments keep
1148 working unchanged. D1 applies to the override too: a GeoLite2 override
1149 past expiry is closed and logged. The file is the operator's, so the
1150 platform never deletes it, and the log line says it must be replaced
1151 and the old one destroyed.
1152
1153## 12. Logging
1154
1155Every message is a fixed category and **never includes an IP address or the
1156MaxMind key**.
1157
1158| Where | Message | Rate |
1159|---|---|---|
1160| edge mirror | `edge: geoip database <sha8> (<type>, built <date>) loaded` | per swap |
1161| edge mirror | `edge: geoip turned off; no database published` | per transition |
1162| edge mirror | `edge: geoip database <sha8> expired (GeoLite2, 30 days after build) and was removed` | per generation |
1163| edge mirror | `edge: geoip blob <sha8> rejected: <category>` | once per sha |
1164| edge mirror | `edge: geoip pointer unreadable; keeping the loaded database` | hourly while failing |
1165| geo | `geo: a GeoIP lookup failed and was treated as a miss` | once a minute, process-wide (existing) |
1166| console | `console: geoip published <sha8> (<type>, <origin>)` | per publish |
1167| console | `console: geoip publish refused: <category>` | per attempt |
1168| console feed | `console: geoip feed <name>: edition <e> rejected: <category>` | once per edition |
1169| console feed | `console: geoip feed misconfigured: <category>` | at startup |
1170
1171## 13. Licensing and docs
1172
1173| `database_type` prefix | Attribution the platform shows | Other duties (operator's) |
1174|---|---|---|
1175| `DBIP-` | `IP Geolocation by DB-IP`, linked to `https://db-ip.com` | CC BY 4.0: attribution on every page that shows or uses the data |
1176| `GeoLite2-` | `This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com` | The GeoLite EULA, including the 30-day update duty the platform enforces through D1, and terms that pass on to the operator's own users. The docs link the EULA and don't paraphrase all of it. |
1177| anything else | none known | The operator's licence for that database |
1178
1179- **The operator owns compliance.** The operator docs say that plainly,
1180 next to the publish verb and the feed settings, and link both providers'
1181 terms.
1182- **The app docs** go in `docs/site/cli.md`, the public reference, as a
1183 new section, "Headers your app receives". No page lists them today
1184 (survey).
1185 - App skills are served from carlosframework.com, outside this repo. Plan
1186 1 files an amadan discussion asking for the skill text to pick up the
1187 section.
1188 - The section must:
1189 - list the headers and explain that they may be absent and why;
1190 - cover `Vary`, with the cache cost (§7);
1191 - say that coordinates are an approximate area;
1192 - spell out the duty: an app that shows visitors anything derived from
1193 these headers or the endpoint must carry the attribution that
1194 `X-Carlos-Geo-Source` names. The endpoint returns the attribution
1195 text, so an app can render it.
1196- **The platform's own pages** that use the data carry the attribution:
1197 the analytics Countries table, per §9, and the new-app region guess if
1198 it ever shows the visitor where it thinks they are.
1199
1200## 14. Testing
1201
1202- **`internal/geo`:**
1203 - `Lookup` against the MaxMind fixtures already in `testdata` (country,
1204 registered-country fallback, subdivision codes from
1205 `GeoIP2-City-Test.mmdb`, no point from a Country database);
1206 - malformed locations;
1207 - `Locate` regressions (§6);
1208 - `Source` sanitising;
1209 - `OpenMMDBTrusted`.
1210- **Swap:** `-race`, with 32 goroutines looking up while 100 swaps happen,
1211 and a `Close` spy proving no close happens while a read lock is held.
1212- **Mirror,** against `release.NewDirStore(t.TempDir())`. There is no in-memory store type (survey):
1213 - first publish, a new sha, the pointer removed, the pointer
1214 unreadable;
1215 - a digest mismatch, an oversize blob, a disk write failure;
1216 - boot with a local generation and no bucket;
1217 - boot with `current` naming a missing file;
1218 - a crash injected between each step of §5.3;
1219 - the old blob deleted between reading the pointer and fetching the
1220 blob;
1221 - GeoLite2 expiry at boot and while running.
1222- **Edge:**
1223 - the forgery tests in §8.4;
1224 - a direct request getting headers;
1225 - a verified hop adopting the sender's headers with no lookup (a lookup
1226 spy proves it);
1227 - none on the door, locally or backhauled;
1228 - none for loopback;
1229 - headers present on a WebSocket upgrade;
1230 - the cache's `Vary` variant selected by country;
1231 - analytics `Countries` and `GeoSources` filled from `geoResult`.
1232- **Publish path:**
1233 - each refusal in §4.1;
1234 - publishing the same file twice;
1235 - X to Y to X;
1236 - publish racing `off` (the mutex);
1237 - a foreign pointer write (the `ifMatch` refusal);
1238 - the sweep;
1239 - the `PutFrom` fallback size limit.
1240- **Feed,** against an `httptest` server standing in for DB-IP and MaxMind:
1241 - a new edition;
1242 - a 404 before the month's file appears;
1243 - an older `build_epoch`;
1244 - a type mismatch, including on the first publish;
1245 - a bad SHA-256;
1246 - a decompression bomb, and a tar with a huge skipped member;
1247 - a cross-host redirect dropping auth;
1248 - HEAD-only when `Last-Modified` hasn't changed;
1249 - 7 consecutive 404s raising the feed reason.
1250- **Analytics:**
1251 - merge across boxes and days;
1252 - a mixed range of old and new days;
1253 - attribution per source.
1254- **Endpoint:**
1255 - each status code;
1256 - role refusals;
1257 - the rate limit;
1258 - the override reader's attribution;
1259 - that no IP ever reaches the log.
1260
1261## 15. Rollout
1262
12630. **The bucket lifecycle rule:**
1264 - `infra/modules/deployment-buckets` gains an
1265 `aws_s3_bucket_lifecycle_configuration` on the deployment bucket, the
1266 first one it has.
1267 - Its single rule is filtered to the `control/geoip/` prefix. It
1268 expires noncurrent versions after 1 day and aborts incomplete
1269 multipart uploads after 1 day.
1270 - It is applied (targeted) before the first GeoLite2 publish. DB-IP
1271 Lite has no destroy duty, so a DB-IP-only deployment can apply it
1272 later.
1273 - For the flagship that means bumping the module ref in
1274 `carlosframework/carloku-infrastructure`, which needs Paul's go-ahead.
12751. Merge plan 1 and roll every box. There is no pointer yet, so no box
1276 sets any header. Every edge strips forged ones.
12772. Run `carlos system geoip status` until its verdict says every fresh box
1278 runs a GeoIP-aware binary (§5.5).
12793. Publish a database by hand (plan 1), or set the feed (plan 2). Check
1280 that every mirroring box reports the pointer's sha.
12814. Check a request to a test app carries the headers. Check a forged header
1282 from outside is replaced, and a door call carries none.
12835. Publish the app docs.
1284
1285Plans 2 and 3 roll the console, and plan 2 also rolls the boxes for the
1286`geoip` rule. The feed does nothing until `CONSOLE_GEOIP_FEED` is set.
1287
1288## 16. Plans
1289
1290| Plan | Contents |
1291|---|---|
1292| 1 | The `control/geoip/` lifecycle rule in `infra/modules/deployment-buckets` (applied later, §15); `geo.Lookup`/`OpenMMDBTrusted`/`Swappable`/`Snapshot`; `release.PutStreamer` and its wrappers; the console publish path and `carlos system geoip publish/status/off`; the edge mirror, its local layout and box status; capture/decide/expose ordering, ingress computing, adopting, `X-Carlos-Hop-Geo`, `setGeo`, the rejected-hop rule and the transport-independent tunnel mark (D2); GeoLite2 expiry, including the `CONSOLE_GEOIP_DB` override's (it moves behind a `geo.Swappable`, is refused at startup when already expired, and is closed at expiry, codex R10-1); analytics `Countries`/`GeoSources` through to the console table; operator and app docs |
1293| 2 | The feed (DB-IP and MaxMind sources, bounded input, integrity, type and monotonic checks, `feed.json`) and the `geoip` box rule |
1294| 3 | The console reader on the mirrored generation, the endpoint, `carlos geo` |
1295
1296## 17. Open items for the plans
1297
1298- **Plan 1, task 1** confirms against real DB-IP Lite and GeoLite2 City
1299 files:
1300 - that DB-IP Lite has no subdivision ISO codes;
1301 - that the country probe's five addresses resolve in both;
1302 - `Verify`'s peak memory (§4.1).
1303- **DB-IP's site says nothing on automated downloads.** Plan 2 fetches at
1304 most once a day, and only when a new month is due. If DB-IP objects, the
1305 feed falls back to manual publishing with no code change.
1306
1307## 18. Review dispositions
1308
1309Round 1 (`CODEX-FINDINGS-GEOIP-R1.md`): every finding accepted.
1310
1311| # | Sev | Disposition |
1312|---|---|---|
1313| 1 | P1 | Accepted. `setGeo` in every `Rewrite`, after hop-header removal, from the same `geoResult` the cache uses (§8.3) |
1314| 2 | P1 | Accepted. One mutex through cleanup, idempotent blobs, a sweep that keeps the current blob (§3) |
1315| 3 | P1 | Accepted. The trust model is now ingress-computes, adopt-downstream, so a backhauled door call carries no headers (§8.2) |
1316| 4 | P1 | Accepted. D1: GeoLite2 expiry at 30 days, enforced by the edges, console and override; alert at 21 days (§0, §5.3, §10.4, §11.3) |
1317| 5 | P2 | Accepted. Immutable generations named by digest, plus an fsynced `current` (§5.2) |
1318| 6 | P2 | Accepted. The console's off transition and empty start (§11.3) |
1319| 7 | P2 | Accepted. Bounds during the copy, a 409 for a concurrent publish, deadlines, cleanup, a `Verify` memory measurement, and no `Verify` on edges (§4.1, §5.3, §10.3) |
1320| 8 | P2 | Accepted. `Opener`/`OpenObject` for reads; `PutStreamer` for uploads, forwarded by every wrapper (§4.1) |
1321| 9 | P2 | Accepted. Shared decode; `Locate` unchanged, with regression cases (§6) |
1322| 10 | P2 | Accepted. Adopted headers fill `geoResult`, so tunnel analytics count countries (§8.2) |
1323| 11 | P2 | Accepted. Pageviews; the read path, API and table are in plan 1; mixed-range test (§9) |
1324| 12 | P2 | Accepted. Per-day `GeoSources` provenance; live attribution from the answering reader; the EULA linked, not paraphrased (§9, §11.1, §13) |
1325| 13 | P2 | Accepted. `apiSystemRoller`, person-only (§4.2) |
1326| 14 | P2 | Accepted. `node_count` in the pointer, comparisons against the etag read, type checked against the feed always (§3, §4.1, §10.3) |
1327| 15 | P2 | Accepted. A box rule through the existing pipelines instead of a new console alert (§10.4) |
1328| 16 | P2 | Accepted. A status block on every new binary, with a mode and a fleet verdict (§5.5) |
1329| 17 | P3 | Accepted. Tunnel boxes have public listeners; the strip covers them (§8.4) |
1330| 18 | P3 | Accepted. DB-IP's HTML checksums named and deliberately not scraped (§10.3) |
1331| 19 | P3 | Accepted. The cache-eviction cost is documented (§7) |
1332| 20 | P3 | Accepted. Coordinates described as an area (§7) |
1333| 21 | P3 | Accepted. Lookup probes instead of metadata (§4.1) |
1334| 22 | P3 | Accepted. #241 quoted in Appendix A |
1335
1336Round 2 (`CODEX-FINDINGS-GEOIP-R2.md`): every finding accepted.
1337
1338| # | Sev | Disposition |
1339|---|---|---|
1340| R1-4 (partly) | | The S3 noncurrent-version lifecycle rule (§15 step 0); override files are the operator's to destroy (§0, §11.3) |
1341| R1-7 (partly) | | A 10-minute deadline on mirror passes; expiry on its own ticker (§5.3) |
1342| R1-14 (partly) | | The shrink check applies on a type match, whatever the origin (§4.1) |
1343| R1-16 (partly) | | The verdict covers stale and unreadable boxes from 7 days of records (§5.5) |
1344| 1 | P1 | Accepted. A rejected hop gets geo `off` (§8.2) |
1345| 2 | P1 | Accepted. The tunnel mark needs a TLS dial (§8.4) |
1346| 3 | P2 | Accepted. Capture, authenticate, decide, expose (§8.1) |
1347| 4 | P2 | Accepted. `Snapshot` under one read lock (§5.4) |
1348| 5 | P2 | Accepted. `X-Carlos-Hop-Geo` carries hit/miss/off (§8.1a, §9) |
1349| 6 | P2 | Accepted. The pointer delete is unconditional under the single-writer mutex (§3) |
1350| 7 | P2 | Accepted. The feed reason is independent of the pointer; an expired pointer is distinct from off (§3, §10.4) |
1351| 8 | P3 | Accepted. Immediate clearing, stated (§10.4) |
1352| 9 | P3 | Accepted. The off bound is one edge poll plus one console poll (§4.2) |
1353
1354Round 3 (`CODEX-FINDINGS-GEOIP-R3.md`): every finding accepted.
1355
1356| # | Sev | Disposition |
1357|---|---|---|
1358| R1-4 (partly) | | The orphan sweep runs at console startup and daily (§3) |
1359| R1-16 (partly) | | The `systemBoxes` inventory, including fleet prefixes (§5.5) |
1360| 1 | P1 | Accepted. `genMu` serializes selection, expiry and cleanup; expiry is checked before download and before install; expired shas are remembered (§5.3) |
1361| 2 | P2 | Accepted. `Swappable.Locate`, and `Config.GeoIP` holds the `Swappable` (§5.4) |
1362| 3 | P2 | Accepted. A coordinate-only `hit` is adopted, and counted as `_unknown` (§8.1a, §9) |
1363| 4 | P2 | Accepted. `status.BoxStale` (§5.5) |
1364
1365Round 4 (`CODEX-FINDINGS-GEOIP-R4.md`): every finding accepted.
1366
1367| # | Sev | Disposition |
1368|---|---|---|
1369| R1-16 (partly) | | Reconciles against `control/edges/`; `LoadBoxesComplete` and strict fleet reads; an incomplete read is never ready (§5.5) |
1370| 1 | P2 | Accepted. The remote edge skips analytics for tunnelled requests, via a trust-free `viaTunnelKey`; this fixes the existing pageview double count too (§9) |
1371| 2 | P2 | Accepted. Upload then a job on the existing system job registry; 202, then the CLI polls (§4.2) |
1372
1373Round 5 (`CODEX-FINDINGS-GEOIP-R5.md`): every finding accepted.
1374
1375| # | Sev | Disposition |
1376|---|---|---|
1377| R1-16 (partly) | | Fleet boxes reconciled via `ListFleetBoxes`; `LoadBoxesComplete` on every prefix (§5.5) |
1378| R4-1 (partly) | | The §8.4 test now asserts zero remote observations and one at the hub |
1379| 1 | P2 | Accepted. The publish slot is reserved before the body is read, and handed to the job (§4.2) |
1380
1381Round 6 (`CODEX-FINDINGS-GEOIP-R6.md`): every finding accepted.
1382
1383| # | Sev | Disposition |
1384|---|---|---|
1385| R1-16 (partly) | | Explicit retirement by deleting the edges record; a stale expected box blocks, with that instruction; a platform retire verb is filed as a discussion (§5.5) |
1386| 1 | P2 | Accepted, through D2: the tunnel mark is transport-independent, so the hub's cache, the app's headers and the analytics always agree (§0, §8.2, §8.4, §9) |
1387| 2 | P2 | Accepted. A dedicated staging directory, cleared at console startup (§4.2) |
1388
1389Round 7 (`CODEX-FINDINGS-GEOIP-R7.md`): every finding accepted.
1390
1391| # | Sev | Disposition |
1392|---|---|---|
1393| R6-1 (partly) | | §16 now says the tunnel mark is transport-independent (D2) |
1394| 1 | P2 | Accepted. An expired candidate is refused, with no force override, and rechecked before the pointer write (§4.1 step 5) |
1395| 2 | P2 | Accepted. `Source` falls back to `mmdb` and is never empty (§6) |
1396| 3 | P2 | Accepted. Attributed sources are never folded into `_other` (§9) |
1397| 4 | P3 | Accepted. Signatures reconciled with the committed code (§4.1, §6) |
1398
1399Round 8 (`CODEX-FINDINGS-GEOIP-R8.md`): every finding accepted.
1400
1401| # | Sev | Disposition |
1402|---|---|---|
1403| R1-16 (partly) | | Only expected boxes block; a retired box unblocks as soon as its edges record is deleted. The retire verb stays a filed platform discussion, not part of GeoIP (§5.5) |
1404| 1 | P2 | Accepted. (namespace, label) identity throughout the verdict (§5.5) |
1405| 2 | P2 | Accepted. A re-read of the pointer before install, one immediate re-run, and pass-start timing, so the 11-minute bound holds (§5.3) |
1406| 3 | P3 | Accepted. §3 step 2 now requires digest and size |
1407
1408Round 9 (`CODEX-FINDINGS-GEOIP-R9.md`): every finding accepted.
1409
1410| # | Sev | Disposition |
1411|---|---|---|
1412| R1-16 (partly) | | Unreadable records block only through expected identities; the final requirement says "every expected box" (§5.5) |
1413| R8-2 (partly) | | 30-second bound on each pointer read; the off bound states it; read failures keep the loaded database and surface in the heartbeat (§5.3) |
1414| 1 | P2 | Accepted. Four pointer states: unknown never ready, off/expired requires empty mirrors, live requires the sha (§5.5) |
1415| 2 | P2 | Accepted. `off` answers 409 while a publish holds the slot (§4.2) |
1416
1417Round 10 (`CODEX-FINDINGS-GEOIP-R10.md`): every finding accepted.
1418
1419| # | Sev | Disposition |
1420|---|---|---|
1421| R8-2 (partly) | | The bounds read 11.5 and 12.5 minutes, given successful reads (§4.2) |
1422| R9-1 (partly) | | The verdict treats a pointer past D1 by the clock as expired (§5.5) |
1423| 1 | P2 | Accepted. Override expiry is in plan 1: a `Swappable`, refused at startup when expired, closed by a ticker (§16) |
1424
1425Round 11 (`CODEX-FINDINGS-GEOIP-R11.md`): accepted.
1426
1427| # | Sev | Disposition |
1428|---|---|---|
1429| 1 | P2 | Accepted. The alert's expired reason is marked or past D1 by the clock, using pointer metadata the mirror keeps after dropping the reader; tests cover delayed marking, restart, fresh publish and off (§10.4; plan 2 Task 4) |
1430
1431## Appendix A. The request (#241, verbatim)
1432
1433> **Expose the platform's GeoIP lookup to apps (headers and/or lookup endpoint)**
1434>
1435> ## Why
1436>
1437> The nearest-region-default spec (`docs/superpowers/specs/2026-10-01-nearest-region-default-design.md`, §6, on branch `edge-replica-placements`) gives the console an optional GeoIP reader: `CONSOLE_GEOIP_DB` names an operator-supplied `.mmdb` (DB-IP Lite or MaxMind GeoLite2), read through `github.com/oschwald/maxminddb-golang`, mapped and opened once per process. Today it serves one caller: guessing a new app's region from the requester's IP.
1438>
1439> Once the platform carries that database, every app on it is one lookup away from "roughly where is this visitor", and none of them should have to ship their own `.mmdb`, handle attribution, or track database updates. The platform should expose the lookup to apps.
1440>
1441> ## What
1442>
1443> A platform geolocation facility that any app can use, backed by the same database and reader the console already has. Two shapes are worth weighing; the agent picking this up should choose one (or both) and say why in the spec:
1444>
1445> 1. **Router-injected request headers.** The edge/router does the lookup on the client IP and sets headers on the proxied request, e.g. `X-Carlos-Geo-Country`, `X-Carlos-Geo-Region`, `X-Carlos-Geo-Lat` / `-Lon`, `X-Carlos-Geo-Source`. Zero integration work for apps; works in every language; covers the common case (country/currency/locale defaults, "nearest region" hints). Cost: a lookup on every request unless cached per connection or per IP.
1446> 2. **A lookup endpoint.** A platform-internal HTTP endpoint apps can call with an arbitrary IP (`GET /geo?ip=…` on the sidecar/hop path, authenticated the way other platform-internal calls are). Needed for anything that isn't "the current request": batch jobs, IPs from logs, webhooks carrying a client IP.
1447>
1448> Headers alone probably cover 90% of app needs; the endpoint covers the rest. Shipping headers first and the endpoint as a follow-on is a reasonable sequence.
1449>
1450> ## Constraints to carry over from the spec
1451>
1452> - Unset `CONSOLE_GEOIP_DB` means the feature is off: headers absent / endpoint returns "unavailable", never a guess. Unopenable database: logged once, feature off, platform still starts.
1453> - Never look up loopback, private, link-local, CGNAT or unspecified addresses.
1454> - Lookup errors are a miss, logged at most once a minute.
1455> - Database replacement is an atomic rename plus restart; whatever process owns the lookup needs the same lifecycle.
1456> - Attribution: DB-IP Lite (CC BY 4.0) requires "IP Geolocation by DB-IP" with a link on pages that show the data. If apps surface the data, the platform docs must tell them about that obligation, and the platform's own pages that use it must carry it. GeoLite2 has its own EULA terms (attribution, keeping the data updated). The operator owns compliance; the docs should say so plainly.
1457> - The reader lives in (or is being added to) the console. If the router or a sidecar needs it, the reader should move to a shared internal package rather than being duplicated.
1458>
1459> ## Open questions
1460>
1461> - Where does the lookup physically run: router, console, sidecar? Which one actually sees the client IP, and which one can memory-map the database? Only one place should open the file.
1462> - Header naming and whether to include coordinates or just country/subdivision. Coordinates make "nearest X" trivial for apps but are more than most need.
1463> - Should apps be able to opt out (an app config flag) to avoid the per-request cost when they don't care?
1464> - Does `carlos` CLI need anything (e.g. `carlos geo <ip>` for debugging what the platform would return)?
1465>
1466> ## Related
1467>
1468> - Nearest-region default spec, §6 (IP lookup), §14 (region table). Build on its reader; do not add a second one.
1469
1470How rev 2 answers the request's constraints:
1471
1472- **"Unset means off":** the off switch is now an absent pointer (R1).
1473 `CONSOLE_GEOIP_DB` keeps its meaning for the console.
1474- **"Atomic rename plus restart":** replaced by the hot swap for the edge
1475 and console mirror (R1). The override keeps the rename-plus-restart rule.
1476- **"Only one place should open the file":** each process that serves
1477 lookups opens it once (one edge per box, plus the console). One file
1478 per box is mapped by every reader on that box, and the bucket holds the
1479 single source.
1480- **"Header naming":** `X-Carlos-Geo-Subdivision` replaces the request's
1481 example `-Region`, because "region" already means an AWS region on this
1482 platform.
1483