carlos / platform Public

geoip spec rev 6 (Astra round 5) and plan 1 to match

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 00ace13c1c48682ed453951dbd71d73ce086432a parent 814df72
3 files changed, +69 −14
  • docs/superpowers/plans/2026-10-06-platform-geoip-plan1.md +10 −4
  • docs/superpowers/specs/2026-10-06-platform-geoip-design.md +39 −10
  • docs/superpowers/specs/CODEX-FINDINGS-GEOIP-R5.md +20 −0

CI cancelled — superseded by a newer push — run details

diff --git a/docs/superpowers/plans/2026-10-06-platform-geoip-plan1.md b/docs/superpowers/plans/2026-10-06-platform-geoip-plan1.md
index d25916679..be83b0d97 100644
--- a/docs/superpowers/plans/2026-10-06-platform-geoip-plan1.md
+++ b/docs/superpowers/plans/2026-10-06-platform-geoip-plan1.md
@@ -19,7 +19,7 @@
**Tech Stack:** Go, `github.com/oschwald/maxminddb-golang` v1.13.1 (already in `go.mod`), `net/http/httputil.ReverseProxy`, AWS SDK v2 S3, OpenTofu (`infra/modules/deployment-buckets`).
-**Spec:** `docs/superpowers/specs/2026-10-06-platform-geoip-design.md` (rev 5). Read §0 rulings first. Measured facts: `docs/superpowers/plans/2026-10-06-platform-geoip-FACTS.md`.
+**Spec:** `docs/superpowers/specs/2026-10-06-platform-geoip-design.md` (rev 6). Read §0 rulings first. Measured facts: `docs/superpowers/plans/2026-10-06-platform-geoip-FACTS.md`.
## Global Constraints
@@ -3679,7 +3679,7 @@ func TestBoundedAddFoldsIntoOther(t *testing.T) {
Replace `newTestMeter` and `onlyDelta` with the helpers `meter_test.go` already uses to build a `Meter` and drain one delta (`grep -n "func new.*Meter\|Drain()" internal/analytics/*_test.go`).
-In `internal/edge/tunnel_geo_e2e_test.go`, give the hub and the remote edge an analytics meter each (`EnableAnalytics` with the app switched on through `SetAnalyticsOn`), send one fresh request and one that the hub answers from its cache, then drain both meters. Across the two meters, pageviews, `Countries["GB"]` and `GeoSources` must each total exactly 2: one per served response, never 3 or 4. The same must hold over a plaintext tunnel, where pageviews total 2 and countries 0.
+In `internal/edge/tunnel_geo_e2e_test.go`, give the hub and the remote edge an analytics meter each (`EnableAnalytics` with the app switched on through `SetAnalyticsOn`), send one fresh request and one that the hub answers from its cache, then drain both meters. The remote meter must record **zero** observations. The hub's meter must record pageviews, `Countries["GB"]` and `GeoSources` of exactly 2 each, one per served response (spec §8.4, §9). The same must hold over a plaintext tunnel, where pageviews total 2 and countries 0.
In `internal/edge/analytics_test.go`, extend `TestEdgeObservesOnlyWhenSwitchedOn` (or add a sibling test). With the City fixture in `e.geo`, a pageview from 81.2.69.142 records `Obs.Country == "GB"`, `GeoState == "hit"` and `GeoSource == "GeoIP2-City"`. Also fill in the Task 10 marker in `tunnel_geo_e2e_test.go`.
@@ -3875,7 +3875,8 @@ git commit -m "analytics: count pageviews by country with per-day source provena
**The verdict** (spec §5.5, codex R4 on R1-16):
- **Expected boxes:** the box names under `control/edges/` (list the prefix; strip `.json`).
-- **Heartbeats:** `status.LoadBoxesComplete` on the platform prefix, plus each fleet prefix read the way `systemBoxes(ctx, true)` reads them.
+- **Expected fleet boxes:** for each fleet from the registry (`reg.ListFleets()`), its active boxes from `reg.ListFleetBoxes(name)`. Open the registry the way `systemBoxes` does (`s.openPassRegistry()`). Fleet boxes publish no edges record (spec §5.5, codex R5 on R1-16).
+- **Heartbeats:** `status.LoadBoxesComplete` on the platform prefix and on every `release.NewPrefixStore(s.cfg.Releases, "fleets/"+name+"/")`. Never `LoadBoxes` or `systemBoxes`, which skip unreadable records.
- **Listed boxes:** every expected box, plus any heartbeat box whose `Updated` is within 7 days.
- An expected box with no heartbeat blocks, as `"<label>: no heartbeat"`.
- An incomplete read blocks, as `"heartbeats: unreadable"`.
@@ -3921,7 +3922,12 @@ Expected: FAIL.
**Publish as upload then job** (spec §4.2, codex R4-2). Split `geoipPublisher.Publish` into two methods. The tests in Task 6 keep calling `Publish`, which becomes `Stage` followed by `Commit`.
-- `Stage(ctx, src) (staged, error)` copies the upload to the temp dir under `copyCapped`. Before copying it takes the mutex with `TryLock`, and returns `errGeoipBusy` if it can't. On success the mutex stays held and passes to the job, which releases it.
+- `Stage(ctx, src) (staged, error)` copies the upload to the temp dir under `copyCapped`. Before reading **any** of the body it takes the mutex with `TryLock`, and returns `errGeoipBusy` if it can't. That reservation is spec §4.2's one slot (codex R5-1). On success the mutex stays held and passes to the job, which owns the temp dir from then on and releases the mutex after removing it. On failure, `Stage` removes the dir and unlocks.
+- Add `TestSecondUploadIsRefusedBeforeItsBodyIsRead` in `apisystemgeoip_test.go`:
+ - start PUT #1 with an `io.Pipe` body that writes 1 KiB and then blocks;
+ - wait until the handler holds the mutex (poll `p.mu.TryLock()`, which fails while it's held);
+ - send PUT #2 with a body that records whether `Read` was ever called;
+ - PUT #2 must get 409, and its body's `Read` must never be called.
- `Commit(ctx, staged, opts) (geoip.Pointer, error)` runs §4.1 steps 3 to 6, then removes the temp dir and unlocks.
The handler:
diff --git a/docs/superpowers/specs/2026-10-06-platform-geoip-design.md b/docs/superpowers/specs/2026-10-06-platform-geoip-design.md
index b87d042e2..a2b7933b4 100644
--- a/docs/superpowers/specs/2026-10-06-platform-geoip-design.md
+++ b/docs/superpowers/specs/2026-10-06-platform-geoip-design.md
@@ -1,7 +1,7 @@
# Platform GeoIP for apps: headers, a mirrored database, a lookup endpoint
**Date:** 2026-10-06
-**Revision:** 5
+**Revision:** 6
**Discussion:** amadan carlos/platform #241 (quoted in Appendix A)
**Branch:** `geoip-for-apps`
@@ -44,6 +44,11 @@
- Publishing becomes an upload followed by a job.
- A tunnelled request is counted once, by the hub.
- The rollout verdict reconciles against the edges records.
+- Rev 6 answers Astra's round 5 (`CODEX-FINDINGS-GEOIP-R5.md`): 1 new P2
+ and 2 partly resolved findings, all accepted, marked "(codex R5-N)".
+ - The upload slot is reserved before any body is read.
+ - Fleet boxes are reconciled through the registry.
+ - The tunnel analytics test is corrected.
## 0. Rulings and decisions
@@ -302,6 +307,18 @@ Under `carlos system`, through the console API:
`http.NewResponseController`, the way the ship door does
(`handleAPIShip`, survey), and wraps the body in `MaxBytesReader` at
1 GiB.
+ - **One slot, reserved first** (codex R5-1). The handler takes the
+ publish mutex (`TryLock`) **before reading any of the body**. If it
+ is held, by another upload still streaming or by a job, the answer
+ is 409 straight away. So at most one upload is ever staged on the
+ console's disk.
+ - The mutex and the temporary directory pass from the handler to
+ the job. The job releases the mutex only after removing the
+ directory.
+ - If the upload fails or is cut off before the job starts, the
+ handler removes the directory and releases the mutex itself.
+ - A test stalls a first upload's body and checks that a second
+ upload gets 409 without any of its body being read.
- The handler only stages the upload: it streams it to the publish
path's temporary file and hashes it.
- It then answers **202 with a job id** straight away. A request that
@@ -492,14 +509,17 @@ fleet boxes write under `fleets/<fleet>/status/boxes/`:
`carlos system geoip status` reconciles two lists (codex R4 on R1-16).
**What it reads:**
-- **Expected boxes:** every box that publishes an edges record
- (`control/edges/<box>.json`, what the home map reads). These boxes serve
- traffic.
-- **Heartbeats:**
- - the platform prefix, read with `status.LoadBoxesComplete`, which says
- when any record couldn't be read, unlike `LoadBoxes`, which skips
- unreadable records silently;
- - every fleet prefix, the way `systemBoxes` reads them, in strict mode.
+- **Expected boxes:**
+ - every platform box that publishes an edges record
+ (`control/edges/<box>.json`, what the home map reads);
+ - every active fleet box the registry lists (`ListFleetBoxes`). Fleet
+ boxes don't publish edges records (`internal/host/edgesfile.go`), so
+ they are reconciled against the registry instead (codex R5 on R1-16).
+- **Heartbeats:** every prefix (the platform's and each fleet's
+ `fleets/<name>/status/boxes/`) is read with `status.LoadBoxesComplete`,
+ which says when any record couldn't be read. Neither `LoadBoxes` nor
+ `systemBoxes`'s strict mode is used, because both skip unreadable
+ records silently.
**Which boxes are listed:**
- Every expected box is listed whatever its age.
@@ -715,7 +735,8 @@ under the same values `setGeo` writes, because both come from `geoResult`.
nomination, including a cache replay;
- a forged header on a tunnel box's public listener;
- a request through a real `tunnelclient` connection that adopts the
- hub's headers and counts the country in the remote box's analytics;
+ hub's headers. The remote box makes **zero** analytics observations,
+ and the hub exactly one (codex R5 on R4-1, §9);
- a door call to an app whose home is another box, which reaches the app
with no geo headers;
- a hop signed with a key the verifier doesn't have yet (the rotation
@@ -1194,6 +1215,14 @@ Round 4 (`CODEX-FINDINGS-GEOIP-R4.md`): every finding accepted.
| 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) |
| 2 | P2 | Accepted. Upload then a job on the existing system job registry; 202, then the CLI polls (§4.2) |
+Round 5 (`CODEX-FINDINGS-GEOIP-R5.md`): every finding accepted.
+
+| # | Sev | Disposition |
+|---|---|---|
+| R1-16 (partly) | | Fleet boxes reconciled via `ListFleetBoxes`; `LoadBoxesComplete` on every prefix (§5.5) |
+| R4-1 (partly) | | The §8.4 test now asserts zero remote observations and one at the hub |
+| 1 | P2 | Accepted. The publish slot is reserved before the body is read, and handed to the job (§4.2) |
+
## Appendix A. The request (#241, verbatim)
> **Expose the platform's GeoIP lookup to apps (headers and/or lookup endpoint)**
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-GEOIP-R5.md b/docs/superpowers/specs/CODEX-FINDINGS-GEOIP-R5.md
new file mode 100644
index 000000000..a2c4ac4fe
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-GEOIP-R5.md
@@ -0,0 +1,20 @@
+Round 5 review of `feef9a5c`.
+
+| Previous finding | Disposition | Evidence from revision 5 |
+|---|---|---|
+| R3-1. Expiry/install races | Resolved | §5.3 retains expiry checks before download and installation, with `genMu` serializing installation, expiry and cleanup. |
+| R3-2. Unprotected console `Locate` | Resolved | §5.4 retains locked `Swappable.Locate` and puts the wrapper in `Config.GeoIP`. |
+| R3-3. Coordinate-only results | Resolved | §8.1a accepts coordinate-only hits; §9 counts their country as `_unknown`. |
+| R3-4. Heartbeat freshness | Resolved | §5.5 retains `status.BoxStale` and the jittered-heartbeat test. |
+| R1-4, carried forward: orphan destruction | Resolved | §3 retains startup and daily orphan sweeps, followed by §15’s noncurrent-version expiration. |
+| R1-16, carried forward: rollout verification | Partly resolved | §5.5 adds expected-box reconciliation through `control/edges/`, but fleet boxes explicitly skip that publisher: [edgesfile.go:698](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/host/edgesfile.go:698). Missing fleet heartbeats remain invisible, and those older than seven days are still discarded. Also, `systemBoxes`’s strict mode still uses `LoadBoxes`, which silently skips individual unreadable records: [apisystemupdate.go:258](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/apisystemupdate.go:258). Reconcile active registered fleet boxes through `ListFleetBoxes`, and use `LoadBoxesComplete` for every heartbeat prefix. |
+| R4-1. Duplicate tunnel analytics | Partly resolved | §9 correctly suppresses the remote observer through `viaTunnelKey`, but §8.4 still requires a test that counts the country in the remote box’s analytics. Change that assertion to zero remote observations and exactly one aggregate observation at the hub. |
+| R4-2. Publication deadline across backhaul | Resolved | §4.2 stages the upload, returns 202, and moves verification and publication into a polled job, with a backhaul timeout regression test. |
+
+1. **P2 — §§4.1–4.2: the upload-and-job split leaves staging concurrency unspecified.** The revised protocol places the job under the publication mutex and refuses another upload while a *job* holds it. It does not reserve the slot while the first request is still uploading. Multiple slow uploads can therefore start before any job exists, each staging up to 1 GiB on the console’s disk. Serializing the subsequent jobs does not bound that disk consumption.
+
+ **Evidence:** the staging and job-lock rules are at [design.md:305](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/docs/superpowers/specs/2026-10-06-platform-geoip-design.md:305). The existing job registry reserves its slot only when `startSystemJob` runs: [apisystemupdate.go:364](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/apisystemupdate.go:364). Reusing the registry after staging supplies no upload admission control.
+
+ **Fix:** reserve one publication slot before reading any upload body, retain it through the handler-to-job handoff, and release it after job cleanup. Explicitly transfer ownership of the temporary file to the job. Test two concurrent uploads with the first body stalled: the second must receive 409 before its body is read.
+
+Verdict: revise.
\ No newline at end of file