carlos / platform Public

GeoIP follow-up (#241): DirStore.Put CAS gate by Stat and streamed hash, so an oversized object at the key is never read into memory (Astra R2-1); restore the thirteen code-review reports lost with the build worktree; Astra round 14 merge-ready.

Squashed from geoip-followups into main.
claude agent, operated by paul@keymail.dev e0bec091ea188d93f06372dc8695b4f3f71742d2 parent 6bdaba3
16 files changed, +529 −5
  • docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R1.md +13 −0
  • docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R10.md +47 −0
  • docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R11.md +44 −0
  • docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R12.md +43 −0
  • docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R13.md +35 −0
  • docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R14.md +37 −0
  • docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R2.md +29 −0
  • docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R3.md +25 −0
  • docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R4.md +33 −0
  • docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R5.md +28 −0
  • docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R6.md +29 −0
  • docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R7.md +33 −0
  • docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R8.md +34 −0
  • docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R9.md +39 −0
  • internal/release/putfrom_test.go +45 −0
  • internal/release/store.go +15 −5

CI passed — run details

diff --git a/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R1.md b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R1.md
new file mode 100644
index 000000000..e0ab9e24b
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R1.md
@@ -0,0 +1,13 @@
+1. **P2 — Readiness can succeed without establishing the desired database state.** `internal/console/apisystemgeoip.go:137,299`: a pointer read failure leaves `ptr=nil`, disabling SHA checks and allowing `ready=true`. After `off`, mirrors still serving the previous database also pass immediately. Represent unknown, off and live states separately: unknown must block; off must require empty mirror SHAs.
+
+2. **P2 — `off` can wait beyond the caller’s timeout.** `internal/console/geoippublish.go:290`: `Off` waits on a mutex held throughout uploading and publishing, potentially for minutes. The CLI and backhaul time out after roughly 30 seconds, while the handler remains blocked. Return a busy response before waiting, or make shutdown a tracked job; cancellation must not leave an unobservable operation queued.
+
+3. **P2 — Console pointer reads are unbounded.** `internal/console/geoippublish.go:78`: `Current` calls `Store.Get`, which reads the entire object into memory. A mistakenly uploaded database or oversized object at `current.json` can exhaust console memory during status, staging or maintenance, despite the defined 64 KiB pointer limit. Stream and cap the read before decoding, while preserving the corresponding ETag for conditional writes.
+
+4. **P2 — Failed directory synchronization still permits deleting the recovery generation.** `internal/edge/geoipmirror.go:166,396`: after replacing generation A with B, a failed directory sync is treated as success and cleanup deletes A. A subsequent crash can recover `current=A` with A missing; boot then discards the surviving B as well. Retain both generations until the new selection is durable. Distinguish a completed rename from a durable commit so error handling never deletes B either.
+
+5. **P3 — The upload reservation test does not verify that the rejected body remains unread.** `internal/console/apisystemgeoip_test.go:126–140`: the spy observes the client’s reader, and its result is discarded. Moving the server’s body read before `TryLock` would still pass the status and mutex assertions. Test with a server-side counting or panicking body and explicitly assert zero reads.
+
+6. **P3 — The countries overflow row loses its required ordering.** `internal/console/analyticsview.go:424–425`: `_other` becomes `other` before `sortedKV`, whose last-row rule recognizes only `_other`. When aggregated overflow exceeds a leading country’s count, “other” appears above individual countries. Preserve the reserved key through sorting and translate its label during rendering.
+
+Verdict: revise.
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R10.md b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R10.md
new file mode 100644
index 000000000..0859d232d
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R10.md
@@ -0,0 +1,47 @@
+| Previous finding | Disposition at `00428606` |
+|---|---|
+| R1-1 — Readiness without established database state | **Resolved.** Unknown pointers block readiness; off and expired pointers require empty mirrors. |
+| R1-2 — `off` waits behind publication | **Resolved.** `TryLock` returns 409 while publication holds the slot. |
+| R1-3 — Unbounded console pointer reads | **Resolved for reads.** Pointer reads are streamed and capped. |
+| R1-4 — Recovery generation deleted after failed synchronization | **Resolved for the reported paths.** Failed installations preserve loaded and retained generations. |
+| R1-5 — Rejected-upload test does not establish zero reads | **Resolved at the reservation boundary.** The panicking-reader test detects reads before reservation. |
+| R1-6 — Countries overflow ordering | **Resolved.** `_other` remains intact until rendering and sorts last. |
+| R2-1 — Feed status writes bypass the read cap | **Partly resolved; P2 remains.** `geoipfeed.go:174` still calls `Put`; `release/store.go:408` reads the entire existing object before checking its precondition. An oversized `feed.json` can exhaust memory. Apply the stat/streamed-hash checks used by `PutFrom`. |
+| R2-2 — Timed-out checks never persist failures | **Resolved for download timeouts.** Status persistence gets a separate bounded context. |
+| R2-3 — Unreadable feed status clears an alert | **Resolved.** Read and parse failures preserve established feed state. |
+| R2-4 — Unusable directory prevents “not loaded” alert | **Resolved.** Pointer state is retained before directory preparation. |
+| R2-5 — Published alert reasons never change while unhealthy | **Resolved.** Reason changes update `Value` and emit an event. |
+| R3-1 — Console follower keeps serving an expired database | **Resolved.** Loaded-reader expiry is checked independently of local state and candidate loading. |
+| R3-2 — Transient status-read failure erases feed history | **Resolved.** Unknown history is not overwritten. |
+| R4-1 — Unbounded bucket operations block startup or publication | **Resolved for the reported operations.** Startup cleanup is asynchronous; maintenance and staging reads have deadlines. |
+| R4-2 — Restart falsely clears a persisted GeoIP alert | **Resolved.** Unknown inputs cannot establish recovery. |
+| R4-3 — Email delivery suppresses active reason changes | **Resolved.** Successful delivery records the last reason per recipient. |
+| R4-4 — Region guesses carry another generation’s attribution | **Resolved.** Location and source are captured under one reader lock. |
+| R5-1 — Unknown feed state suppresses confirmed expiry | **Resolved.** Established unhealthy conditions precede the unknown-state guard. |
+| R6-1 — Failed startup deletion leaves a disabled feed alert firing indefinitely | **Resolved.** Deletion retries with bounded attempts and capped backoff. |
+| R7-1 — Unbounded feed edition metadata makes control records unreadable | **Resolved for the reported input.** Editions are normalized HTTP dates; oversized serialized pointers are refused. |
+| R7-2 — Unchanged-edition checks leave the feed reported as “not run yet” | **Resolved.** Confirmed editions update success history. |
+| R8-1 — MMDB verification indefinitely holds the publish slot | **Resolved for the reported traversal attacks.** Verification runs in a child killed at its deadline; `Commit` defers staging cleanup and slot release. |
+| R9-1 — Malformed MMDB metadata can crash the entire console | **Resolved for the reported arithmetic panic.** Parsing occurs in the child, whose failure refuses publication without replacing the pointer. |
+
+New findings:
+
+1. **P1 — The verifier child has no memory bound, so a tiny database can still exhaust the console’s memory.**
+ [internal/console/geoipverify.go:91](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/geoipverify.go:91)
+
+ The child has a runtime deadline and capped output, but no independent memory limit. In the pinned `maxminddb-golang` decoder, the four-byte map header `FF FF FF FF` declares **16,843,036 entries**. The decoder allocates that map before checking whether its entries exist. Nested maps can therefore cause gigabytes of allocations from a tiny malformed data section, before verification or the country probe rejects it.
+
+ The child inherits the console’s service cgroup. These allocations can trigger service-level or host-level out-of-memory handling, taking down the console or other processes before the five-minute timeout. Moving parsing to a child contains ordinary panics but does not contain this resource failure.
+
+ **Fix:** enforce an allocation budget before invoking the unsafe decoder, or run verification under an independent hard memory limit sized to preserve console headroom. Add a regression using oversized collection declarations under a small enforced budget, asserting parent survival, slot release, staging cleanup, and preservation of the previous pointer.
+
+2. **P2 — Retained recovery generations can survive their GeoLite2 expiry indefinitely.**
+ [internal/edge/geoipmirror.go:445](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/edge/geoipmirror.go:445), [internal/edge/geoipmirror.go:484](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/edge/geoipmirror.go:484)
+
+ Start with GeoLite2 generation A, then publish DB-IP generation B. If the directory sync after selecting B fails once, A is retained in `unsynced`. Even after the filesystem recovers, subsequent polls return immediately because B is already loaded. They never retry the pending sync or clean up A. `ExpireTick` checks only B, which never expires, so A remains on disk beyond D1 indefinitely unless another publication, restart, or `off` occurs.
+
+ **Fix:** retry pending synchronization when the pointer still names the loaded generation, then clean up retained files after synchronization succeeds. Also track retained generations’ expiry so expired GeoLite2 copies are removed even while synchronization remains unavailable. Test a single sync failure during a GeoLite2-to-DB-IP transition, followed by unchanged-pointer polls and advancement past A’s expiry.
+
+Validation was static, including inspection of the pinned decoder. Targeted Go tests could not start because creating the build directory failed with “read-only file system”; runtime tests and cross-compilation remain unverified.
+
+Verdict: revise.
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R11.md b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R11.md
new file mode 100644
index 000000000..314fdafea
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R11.md
@@ -0,0 +1,44 @@
+| Previous finding | Disposition at `98c0139b` |
+|---|---|
+| R1-1 — Readiness without established database state | **Resolved.** Unknown pointers block readiness; off and expired pointers require empty mirrors. |
+| R1-2 — `off` waits behind publication | **Resolved.** `TryLock` returns 409 while publication holds the slot. |
+| R1-3 — Unbounded console pointer reads | **Resolved for reads.** Pointer reads are streamed and capped. |
+| R1-4 — Recovery generation deleted after failed synchronization | **Resolved for the reported paths.** Failed installations preserve loaded and retained generations. |
+| R1-5 — Rejected-upload test does not establish zero reads | **Resolved at the reservation boundary.** The panicking-reader test detects reads before reservation. |
+| R1-6 — Countries overflow ordering | **Resolved.** `_other` remains intact until rendering and sorts last. |
+| R2-1 — Feed status writes bypass the read cap | **Still partly resolved; P2 remains.** `geoipfeed.go:174` calls `Put`; `release/store.go:408` reads the entire existing object before checking its precondition. An oversized `feed.json` can exhaust memory. Apply the stat/streamed-hash checks used by `PutFrom`. |
+| R2-2 — Timed-out checks never persist failures | **Resolved for download timeouts.** Status persistence gets a separate bounded context. |
+| R2-3 — Unreadable feed status clears an alert | **Resolved.** Read and parse failures preserve established feed state. |
+| R2-4 — Unusable directory prevents “not loaded” alert | **Resolved.** Pointer state is retained before directory preparation. |
+| R2-5 — Published alert reasons never change while unhealthy | **Resolved.** Reason changes update `Value` and emit an event. |
+| R3-1 — Console follower keeps serving an expired database | **Resolved.** Loaded-reader expiry is checked independently of local state and candidate loading. |
+| R3-2 — Transient status-read failure erases feed history | **Resolved.** Unknown history is not overwritten. |
+| R4-1 — Unbounded bucket operations block startup or publication | **Resolved for the reported operations.** Startup cleanup is asynchronous; maintenance and staging reads have deadlines. |
+| R4-2 — Restart falsely clears a persisted GeoIP alert | **Resolved.** Unknown inputs cannot establish recovery. |
+| R4-3 — Email delivery suppresses active reason changes | **Resolved.** Successful delivery records the last reason per recipient. |
+| R4-4 — Region guesses carry another generation’s attribution | **Resolved.** Location and source are captured under one reader lock. |
+| R5-1 — Unknown feed state suppresses confirmed expiry | **Resolved.** Established unhealthy conditions precede the unknown-state guard. |
+| R6-1 — Failed startup deletion leaves a disabled feed alert firing indefinitely | **Resolved.** Deletion retries with bounded attempts and capped backoff. |
+| R7-1 — Unbounded feed edition metadata makes control records unreadable | **Resolved.** Editions are normalized HTTP dates; oversized serialized pointers are refused. |
+| R7-2 — Unchanged-edition checks leave the feed reported as “not run yet” | **Resolved.** Confirmed editions update success history. |
+| R8-1 — MMDB verification indefinitely holds the publish slot | **Resolved for the reported traversal attacks.** Verification runs in a child killed at its deadline; staging cleanup and slot release are deferred. |
+| R9-1 — Malformed MMDB metadata can crash the entire console | **Resolved for the reported arithmetic panic.** Initial parsing occurs in the verifier child. |
+| R10-1 — Verifier child has no memory bound | **Resolved on Linux for the reported allocation attack.** The child applies a 512 MiB `RLIMIT_DATA` before parsing and refuses verification if setting it fails. |
+| R10-2 — Retained recovery generations survive GeoLite2 expiry | **Resolved for the reported synchronization failure.** Unchanged-pointer polls retry synchronization and cleanup; retained generations receive independent expiry checks. |
+
+New findings:
+
+1. **P1 — A database can pass verification and then crash serving processes during a lookup.**
+ [internal/console/geoipverify.go:58](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/geoipverify.go:58), [internal/geo/mmdb.go:186](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/geo/mmdb.go:186)
+
+ The pinned library’s `Verify` decodes records into generic values. Live lookups decode into `mmdbRecord`, which takes a different path: `decodeStruct` skips unknown fields using `nextValueOffset`. That function recursively calls itself once per skipped value, without the decoder’s depth limit.
+
+ A crafted database can return ordinary countries for the five probe addresses while placing a large flat boolean array in an unknown field on another public network. Generic verification iterates the array; sixteen million elements require roughly 256 MB of array storage, within the child’s memory budget. The probes never exercise that record’s struct-decoding path.
+
+ After publication, a lookup in that network creates millions of recursive stack frames in the serving process. Stack exhaustion is fatal, so a visitor request can terminate an edge, and an authenticated `/geo` request can terminate the console. The verifier child’s memory limit and deadline do not protect these later lookups.
+
+ **Fix:** validate every reachable record through the production decoding schema inside the bounded child, with a per-record complexity limit, or replace the recursive skip path with bounded iteration. Add a regression containing a large ignored array outside the probe networks and verify that it cannot reach an unsafe serving lookup.
+
+Validation was static, including inspection of the pinned decoder. Targeted Go tests could not start because creating the build directory failed with “read-only file system”; runtime reproduction and cross-compilation remain unverified.
+
+Verdict: revise.
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R12.md b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R12.md
new file mode 100644
index 000000000..63c3f61dc
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R12.md
@@ -0,0 +1,43 @@
+| Previous finding | Disposition at `d16d43c6` |
+|---|---|
+| R1-1 — Readiness without established database state | **Resolved.** Unknown pointers block readiness; off and expired pointers require empty mirrors. |
+| R1-2 — `off` waits behind publication | **Resolved.** Publication holds the slot; `off` returns 409 immediately. |
+| R1-3 — Unbounded console pointer reads | **Resolved for reads.** Pointer reads are streamed and capped. |
+| R1-4 — Recovery generation deleted after failed synchronization | **Resolved for the reported paths.** Failed installations preserve loaded and retained generations. |
+| R1-5 — Rejected-upload test does not establish zero reads | **Resolved.** The panicking-reader test detects reads before reservation. |
+| R1-6 — Countries overflow ordering | **Resolved.** `_other` remains intact until rendering and sorts last. |
+| R2-1 — Feed status writes bypass the read cap | **Still partly resolved; P2 remains.** `geoipfeed.go:174` calls `Put`; `release/store.go:408` reads the entire existing object before checking its precondition. An oversized `feed.json` can exhaust memory. Apply the stat and streamed-hash checks used by `PutFrom`. |
+| R2-2 — Timed-out checks never persist failures | **Resolved for download timeouts.** Persistence receives a separate bounded context. |
+| R2-3 — Unreadable feed status clears an alert | **Resolved.** Read and parse failures preserve established feed state. |
+| R2-4 — Unusable directory prevents “not loaded” alert | **Resolved.** Pointer state is retained before directory preparation. |
+| R2-5 — Published alert reasons never change while unhealthy | **Resolved.** Reason changes update `Value` and emit an event. |
+| R3-1 — Console follower keeps serving an expired database | **Resolved.** Loaded-reader expiry is checked independently of local state and candidate loading. |
+| R3-2 — Transient status-read failure erases feed history | **Resolved.** Unknown history is not overwritten. |
+| R4-1 — Unbounded bucket operations block startup or publication | **Resolved for the reported operations.** Startup cleanup is asynchronous; maintenance and staging reads have deadlines. |
+| R4-2 — Restart falsely clears a persisted GeoIP alert | **Resolved.** Unknown inputs cannot establish recovery. |
+| R4-3 — Email delivery suppresses active reason changes | **Resolved.** Successful delivery records the last reason per recipient. |
+| R4-4 — Region guesses carry another generation’s attribution | **Resolved.** Location and source are captured under one reader lock. |
+| R5-1 — Unknown feed state suppresses confirmed expiry | **Resolved.** Established unhealthy conditions precede the unknown-state guard. |
+| R6-1 — Failed startup deletion leaves a disabled feed alert firing indefinitely | **Resolved.** Deletion retries with bounded attempts and capped backoff. |
+| R7-1 — Unbounded feed edition metadata makes control records unreadable | **Resolved.** Editions are normalized HTTP dates; oversized serialized pointers are refused. |
+| R7-2 — Unchanged-edition checks leave the feed reported as “not run yet” | **Resolved.** Confirmed editions update success history. |
+| R8-1 — MMDB verification indefinitely holds the publish slot | **Resolved for the reported traversal attacks.** Verification runs in a child killed at its deadline; commit cleanup releases staging and the slot. |
+| R9-1 — Malformed MMDB metadata can crash the entire console | **Resolved for the reported arithmetic panic.** Initial parsing occurs in the verifier child. |
+| R10-1 — Verifier child has no memory bound | **Resolved on Linux for the reported allocation attack.** The child requires a 512 MiB `RLIMIT_DATA` before parsing. |
+| R10-2 — Retained recovery generations survive GeoLite2 expiry | **Resolved for the reported synchronization failure.** Unchanged-pointer polls retry synchronization; retained generations receive independent expiry checks. |
+| R11-1 — Verified database crashes serving processes during lookup | **Resolved for the reported stack-exhaustion path.** `CheckRecords` decodes every reachable record through `mmdbRecord` inside the bounded verifier child before publication. |
+
+New findings:
+
+1. **P1 — Underscore header spellings bypass stripping and become trusted geo headers in WSGI apps.**
+ [internal/geoip/answer.go:78](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/geoip/answer.go:78)
+
+ `StripAll` matches only the hyphenated namespace. Go accepts header names containing underscores, so `X_Carlos_Geo_Country: KP`, `X_Carlos_Geo_Source: forged`, and `X_Carlos_Hop_Geo: hit;src=forged` survive both ingress stripping and `setGeo`.
+
+ Python’s WSGI request handler converts hyphens to underscores and uppercases names. Consequently, these attacker-controlled fields become `HTTP_X_CARLOS_GEO_COUNTRY`, `HTTP_X_CARLOS_GEO_SOURCE`, and `HTTP_X_CARLOS_HOP_GEO`: the same application keys as the platform’s headers. This works when GeoIP is off. With a database loaded, the attacker can also send an unsigned `X-Carlos-Hop: 1`; the rejected-hop path suppresses legitimate geo headers while preserving the underscore aliases.
+
+ **Fix:** normalize underscores to hyphens when checking whether a name belongs to the reserved namespace, then delete the original header key. Apply this to the whole geo namespace and the internal hop-geo name. Add forgery tests covering mixed separators and a backend that performs WSGI header normalization.
+
+Validation: static review plus an in-memory reproduction using Python’s actual WSGI header conversion. Targeted Go tests could not start because the sandbox refused creation of the build directory; Go runtime verification and cross-compilation remain unverified.
+
+Verdict: revise.
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R13.md b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R13.md
new file mode 100644
index 000000000..e4ea11814
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R13.md
@@ -0,0 +1,35 @@
+| Previous finding | Disposition at `b0b97e97` |
+|---|---|
+| R1-1 — Readiness without established database state | **Resolved.** Unknown pointers block readiness; off and expired pointers require empty mirrors. |
+| R1-2 — `off` waits behind publication | **Resolved.** `off` returns 409 immediately while publication holds the slot. |
+| R1-3 — Unbounded console pointer reads | **Resolved for reads.** Pointer reads are streamed and capped. |
+| R1-4 — Recovery generation deleted after failed synchronization | **Resolved for the reported paths.** Failed installations preserve loaded and retained generations. |
+| R1-5 — Rejected-upload test does not establish zero reads | **Resolved.** The panicking-reader test detects reads before reservation. |
+| R1-6 — Countries overflow ordering | **Resolved.** `_other` remains intact until rendering and sorts last. |
+| R2-1 — Feed status writes bypass the read cap | **Still partly resolved; P2 remains.** [geoipfeed.go:174](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/geoipfeed.go:174) calls `Put`; [store.go:408](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/release/store.go:408) reads the entire existing object before checking its precondition. An oversized `feed.json` can exhaust console memory. Apply the stat and streamed-hash checks used by `PutFrom`. |
+| R2-2 — Timed-out checks never persist failures | **Resolved for download timeouts.** Persistence receives a separate bounded context. |
+| R2-3 — Unreadable feed status clears an alert | **Resolved.** Read and parse failures preserve established feed state. |
+| R2-4 — Unusable directory prevents “not loaded” alert | **Resolved.** Pointer state is retained before directory preparation. |
+| R2-5 — Published alert reasons never change while unhealthy | **Resolved.** Reason changes update `Value` and emit an event. |
+| R3-1 — Console follower keeps serving an expired database | **Resolved.** Loaded-reader expiry is checked independently of local state and candidate loading. |
+| R3-2 — Transient status-read failure erases feed history | **Resolved.** Unknown history is not overwritten. |
+| R4-1 — Unbounded bucket operations block startup or publication | **Resolved for the reported operations.** Startup cleanup is asynchronous; maintenance and staging reads have deadlines. |
+| R4-2 — Restart falsely clears a persisted GeoIP alert | **Resolved.** Unknown inputs cannot establish recovery. |
+| R4-3 — Email delivery suppresses active reason changes | **Resolved.** Successful delivery records the last reason per recipient. |
+| R4-4 — Region guesses carry another generation’s attribution | **Resolved.** Location and source are captured under one reader lock. |
+| R5-1 — Unknown feed state suppresses confirmed expiry | **Resolved.** Established unhealthy conditions precede the unknown-state guard. |
+| R6-1 — Failed startup deletion leaves a disabled feed alert firing indefinitely | **Resolved.** Deletion retries with bounded attempts and capped backoff. |
+| R7-1 — Unbounded feed edition metadata makes control records unreadable | **Resolved.** Editions are normalized HTTP dates; oversized serialized pointers are refused. |
+| R7-2 — Unchanged-edition checks leave the feed reported as “not run yet” | **Resolved.** Confirmed editions update success history. |
+| R8-1 — MMDB verification indefinitely holds the publish slot | **Resolved for the reported traversal attacks.** Verification runs in a child killed at its deadline; commit cleanup releases staging and the slot. |
+| R9-1 — Malformed MMDB metadata can crash the entire console | **Resolved for the reported arithmetic panic.** Initial parsing occurs in the verifier child. |
+| R10-1 — Verifier child has no memory bound | **Resolved on Linux for the reported allocation attack.** The child requires a 512 MiB `RLIMIT_DATA` before parsing. |
+| R10-2 — Retained recovery generations survive GeoLite2 expiry | **Resolved for the reported synchronization failure.** Unchanged-pointer polls retry synchronization; retained generations receive independent expiry checks. |
+| R11-1 — Verified database crashes serving processes during lookup | **Resolved for the reported stack-exhaustion path.** `CheckRecords` decodes reachable records through the production schema inside the bounded verifier child before publication. |
+| R12-1 — Underscore header spellings bypass stripping | **Resolved.** Reserved-name checks normalize underscores to hyphens before deleting original keys. This covers mixed separators, the whole geo namespace, and hop-geo. Proxy tests reject names outside the five exact app headers. |
+
+New findings: none confirmed.
+
+Validation: static review, including plans 2 and 3 and feed caps, checksum, redirects, and credential handling. Targeted Go tests could not start because the read-only sandbox refused build-directory creation. Runtime tests and cross-compilation remain unverified.
+
+Verdict: revise.
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R14.md b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R14.md
new file mode 100644
index 000000000..9914729f0
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R14.md
@@ -0,0 +1,37 @@
+| Previous finding | Disposition at `4287f396` |
+|---|---|
+| R1-1 — Readiness without established database state | Remains resolved. |
+| R1-2 — `off` waits behind publication | Remains resolved. |
+| R1-3 — Unbounded console pointer reads | Remains resolved for reads. |
+| R1-4 — Recovery generation deleted after failed synchronization | Remains resolved for the reported paths. |
+| R1-5 — Rejected-upload test does not establish zero reads | Remains resolved. |
+| R1-6 — Countries overflow ordering | Remains resolved. |
+| R2-1 — Feed status writes bypass the read cap | **Resolved.** `DirStore.Put` now uses `os.Stat` for create-only writes and `hashFile` for replacement checks, preserving conditional-write semantics with bounded memory. The added regression test checks allocations against a 256 MiB existing object and verifies successful replacement. |
+| R2-2 — Timed-out checks never persist failures | Remains resolved for download timeouts. |
+| R2-3 — Unreadable feed status clears an alert | Remains resolved. |
+| R2-4 — Unusable directory prevents “not loaded” alert | Remains resolved. |
+| R2-5 — Published alert reasons never change while unhealthy | Remains resolved. |
+| R3-1 — Console follower keeps serving an expired database | Remains resolved. |
+| R3-2 — Transient status-read failure erases feed history | Remains resolved. |
+| R4-1 — Unbounded bucket operations block startup or publication | Remains resolved for the reported operations. |
+| R4-2 — Restart falsely clears a persisted GeoIP alert | Remains resolved. |
+| R4-3 — Email delivery suppresses active reason changes | Remains resolved. |
+| R4-4 — Region guesses carry another generation’s attribution | Remains resolved. |
+| R5-1 — Unknown feed state suppresses confirmed expiry | Remains resolved. |
+| R6-1 — Failed startup deletion leaves a disabled feed alert firing indefinitely | Remains resolved. |
+| R7-1 — Unbounded feed edition metadata makes control records unreadable | Remains resolved. |
+| R7-2 — Unchanged-edition checks leave the feed reported as “not run yet” | Remains resolved. |
+| R8-1 — MMDB verification indefinitely holds the publish slot | Remains resolved for the reported traversal attacks. |
+| R9-1 — Malformed MMDB metadata can crash the entire console | Remains resolved for the reported arithmetic panic. |
+| R10-1 — Verifier child has no memory bound | Remains resolved on Linux for the reported allocation attack. |
+| R10-2 — Retained recovery generations survive GeoLite2 expiry | Remains resolved for the reported synchronization failure. |
+| R11-1 — Verified database crashes serving processes during lookup | Remains resolved for the reported stack-exhaustion path. |
+| R12-1 — Underscore header spellings bypass stripping | Remains resolved. |
+
+Unchanged dispositions carry forward round 13’s conclusions; this review covers `6bdaba34..4287f396`.
+
+New findings: none confirmed.
+
+Validation: static review of both changed Go files and relevant callers. Scoped whitespace and formatting checks passed. Targeted Go tests could not start because the read-only sandbox refused build-directory creation; runtime tests and cross-compilation remain unverified.
+
+Verdict: merge-ready.
\ No newline at end of file
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R2.md b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R2.md
new file mode 100644
index 000000000..145f50481
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R2.md
@@ -0,0 +1,29 @@
+| Round 1 finding | Disposition at `ee3855fc` |
+|---|---|
+| R1-1 — Readiness without established database state | **Resolved.** Unknown pointers block readiness; off and expired pointers require empty mirrors. See `internal/console/apisystemgeoip.go:140,345,366`. |
+| R1-2 — `off` waits beyond caller timeout | **Resolved.** `Off` uses `TryLock`, and the API returns 409 while publishing. See `internal/console/geoippublish.go:342`. |
+| R1-3 — Unbounded console pointer reads | **Resolved.** `readPointerCapped` streams at most 64 KiB + 1 and preserves the conditional-write ETag. See `internal/console/geoippublish.go:158`. |
+| R1-4 — Recovery generation deleted after failed synchronization | **Partly resolved; P2 remains.** `internal/edge/geoipmirror.go:418,438–442` retains only the immediately previous generation. With durable A followed by B and C while directory synchronization keeps failing, installing C deletes A. A crash can still recover `current=A` with A missing. Retain every potentially recoverable generation until synchronization succeeds, or suspend further installations. Test three generations with failures at both directory-sync sites. |
+| R1-5 — Rejected-upload test does not establish zero reads | **Resolved at the reservation boundary.** The new `TestStageNeverReadsWhenTheSlotIsHeld` passes a panicking reader directly to `Stage`. The original HTTP test remains weak, but the new test detects reads before reservation. See `internal/console/geoippublish_test.go:375`. |
+| R1-6 — Countries overflow ordering | **Resolved.** `_other` survives sorting and becomes “other” during rendering. See `internal/console/analyticsview.go:415,425`. |
+
+New findings:
+
+1. **P2 — Feed status writes bypass the 64 KiB read cap.**
+ `internal/console/geoipfeed.go:155`: `writeStatus` calls `Store.Get` solely for the ETag, reading the entire existing `feed.json` into memory. An oversized object is rejected by `readStatus`, then read without bounds at the end of the same check, potentially exhausting console memory. Obtain the ETag through a capped streaming read, preferably retaining it from the initial status read.
+
+2. **P2 — Timed-out feed checks never persist their failure count.**
+ `internal/console/geoipfeed.go:125,336`: the download and final status write share the 15-minute context. When a provider stalls until that deadline, the subsequent S3 read/write receives an already-cancelled context. Repeated timeouts therefore never accumulate seven recorded failures; a fresh deployment can remain without a database and without a feed alert indefinitely. Reserve time for status persistence or give it a separate bounded context. Test repeated deadline failures using a store that honors cancellation.
+
+3. **P2 — An unreadable feed status clears an ongoing failure alert.**
+ `internal/edge/geoipmirror.go:488–498`: any read or parse error replaces the last known feed state with `false, ""`. With reason 5 firing and no other reason present, one transient bucket failure produces code 0 and sends a recovery notification. Another successful read must then sustain the unchanged failure for another 30 minutes. Preserve the previous state on read/parse failures; clear it only after a valid healthy status or confirmed deletion.
+
+4. **P2 — An unusable mirror directory prevents the “not loaded” alert.**
+ `internal/edge/geoipmirror.go:253–257`: `poll` returns on `MkdirAll` failure before reading the pointer or feed status. On a fresh box with an unwritable directory, `pointerPresent` remains false and `wantSince` remains zero, so a published database can remain unloaded indefinitely without reason 4 firing. Read and retain the control-plane state independently of local directory preparation, then report the installation failure.
+
+5. **P2 — Published alert reasons never change while GeoIP remains unhealthy.**
+ `internal/metrics/alert.go:181–190,264`, `internal/host/metrics_loop.go:500`: the fraction evaluator preserves `Value` while firing, and the host publishes only transitions. An alert raised at GeoLite2 day 21 retains reason 2 after day 30, even after the database is removed and the mirror returns higher-priority reason 3. Persist changes to the active GeoIP reason without clearing the alert or restarting its sustain window.
+
+Validation was limited to static review. Targeted tests could not start because Go’s work directory was read-only; cross-compilation remains unverified.
+
+Verdict: revise.
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R3.md b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R3.md
new file mode 100644
index 000000000..45998bc39
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R3.md
@@ -0,0 +1,25 @@
+| Previous finding | Disposition at `a10120de` |
+|---|---|
+| R1-1 — Readiness without established database state | **Resolved.** Unknown pointers block readiness; off and expired pointers require empty mirrors (`internal/console/apisystemgeoip.go:140,345,366`). |
+| R1-2 — `off` waits beyond caller timeout | **Resolved.** `Off` uses `TryLock`; concurrent publication returns 409 (`internal/console/geoippublish.go:343`). |
+| R1-3 — Unbounded console pointer reads | **Resolved.** Pointer reads remain streamed and capped, retaining the ETag (`internal/console/geoippublish.go:163`). |
+| R1-4 — Recovery generation deleted after failed synchronization | **Partly resolved; P2 remains.** A → B → C now retains every potentially recoverable generation. However, republishing retained A while directory synchronization still fails can reach `internal/edge/geoipmirror.go:429–432`: a failure writing `current` unconditionally deletes A. A crash can then restore durable `current=A` without its database. Protect retained generations on installation-error paths too, and test A → B → A with both directory-sync sites failing and the final `current` write failing. |
+| R1-5 — Rejected-upload test does not establish zero reads | **Resolved at the reservation boundary.** The panicking-reader test detects reads before slot reservation (`internal/console/geoippublish_test.go:375`). |
+| R1-6 — Countries overflow ordering | **Resolved.** `_other` survives sorting and is relabelled during rendering (`internal/console/analyticsview.go:415,425`). |
+| R2-1 — Feed status writes bypass the read cap | **Resolved.** The writer uses the ETag from the initial capped read (`internal/console/geoipfeed.go:146,161`). |
+| R2-2 — Timed-out checks never persist failures | **Resolved.** Status persistence has a separate bounded context detached from the expired check (`internal/console/geoipfeed.go:131`). |
+| R2-3 — Unreadable feed status clears an alert | **Resolved in the mirror.** Read and parse failures preserve the previous state; confirmed deletion clears it (`internal/edge/geoipmirror.go:497`). |
+| R2-4 — Unusable directory prevents “not loaded” alert | **Resolved.** Control-plane state is retained before directory preparation (`internal/edge/geoipmirror.go:245,315`). |
+| R2-5 — Published alert reasons never change while unhealthy | **Resolved.** A changed reason updates `Value` and emits an event for publication without restarting sustain (`internal/metrics/alert.go:269`). |
+
+New findings:
+
+1. **P2 — The console follower can keep serving an expired database.**
+ `internal/console/geoipfollower.go:48–61`: expiry is checked against the newly read `current`, not the reader already loaded. Load GeoLite2 A, then make `current` unreadable—or have it select an unopenable replacement B. Once A passes its 30-day limit, every tick still returns while retaining A, so `/geo` and region guesses continue using expired data. Check the loaded reader’s own source/build on every tick, independently of local-state errors, and validate a candidate’s own metadata before swapping. Add expiry tests covering both failure paths.
+
+2. **P2 — A transient status-read failure erases the feed’s failure history.**
+ `internal/console/geoipfeed.go:146–168`: a read error becomes an empty status and empty ETag. If the subsequent create-only write conflicts, `writeStatus` deletes the existing object and replaces it. With seven failures already recorded, one transient read failure followed by another failed download therefore replaces the count with one. The mirror reads a valid non-failing status and clears the alert despite no successful check. Distinguish confirmed absence from read failure; retry a capped read before updating, or preserve the existing object when its history is unknown. Test a failed status read followed by successful writes against an existing failing status.
+
+Validation was static. Targeted tests could not start because Go’s work directory was read-only; cross-compilation remains unverified.
+
+Verdict: revise.
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R4.md b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R4.md
new file mode 100644
index 000000000..819fc44b9
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R4.md
@@ -0,0 +1,33 @@
+| Previous finding | Disposition at `034727d4` |
+|---|---|
+| R1-1 — Readiness without established database state | **Resolved.** Unknown pointers block readiness; off and expired pointers require empty mirrors. |
+| R1-2 — `off` waits beyond caller timeout | **Resolved.** `TryLock` refuses concurrent publication with 409. |
+| R1-3 — Unbounded console pointer reads | **Resolved for pointer reads.** `readPointerCapped` streams and caps the object. |
+| R1-4 — Recovery generation deleted after failed synchronization | **Resolved.** Installation-error cleanup preserves loaded and retained generations through `removeGenerationLocked`. |
+| R1-5 — Rejected-upload test does not establish zero reads | **Resolved at the reservation boundary.** The panicking-reader test detects reads before slot reservation. |
+| R1-6 — Countries overflow ordering | **Resolved.** `_other` remains available to the sorter and is relabelled during rendering. |
+| R2-1 — Feed status writes bypass the read cap | **Partly resolved; P2 remains for `DirStore`.** `geoipfeed.go:174` calls `Put`, whose implementation at `internal/release/store.go:408` reads the entire existing object with `os.ReadFile`. An oversized `feed.json` therefore still reaches memory after its capped read rejects it, even before a create-only conflict. Apply the stat/streamed-hash conditional-write checks already used by `PutFrom`; test the underlying write path too. |
+| R2-2 — Timed-out checks never persist failures | **Resolved.** Status writes receive a separate bounded context. |
+| R2-3 — Unreadable feed status clears an alert | **Resolved within a running mirror.** Read and parse failures preserve its previous state. The cold-start case is new finding 2 below. |
+| R2-4 — Unusable directory prevents “not loaded” alert | **Resolved.** Control-plane state is retained before directory preparation. |
+| R2-5 — Published alert reasons never change while unhealthy | **Resolved for publication.** Reason changes update `Value` and emit an event. The email consumer has a separate defect described below. |
+| R3-1 — Console follower keeps serving an expired database | **Resolved.** Every tick checks the loaded reader independently of `current`; candidates also receive a metadata expiry check. |
+| R3-2 — Transient status-read failure erases feed history | **Resolved.** An unsuccessful status read marks history unknown and prevents replacement. |
+
+New findings:
+
+1. **P2 — Unbounded bucket operations can prevent startup or permanently occupy the publish slot.**
+ `internal/console/server.go:1444,1448` and `internal/console/apisystemgeoip.go:53`: startup supplies `context.Background()` to synchronous feed-status deletion and to maintenance operations holding the publisher mutex. Manual staging also reads the pointer without an operation deadline; the HTTP read deadline only bounds the incoming connection. A bucket request that connects but stalls can therefore prevent console startup, or leave publication and `off` returning 409 indefinitely. Give these operations bounded contexts, keep optional cleanup off the synchronous startup path, and test cancellation with a context-aware stalled store.
+
+2. **P2 — Restarting the agent can falsely clear a persisted GeoIP alert.**
+ `internal/edge/geoipmirror.go:542–558`: a new mirror initializes pointer and feed state as false, and returns reason 0 before establishing either state. Restart with reason 5 persisted, then make `feed.json` unreadable while alert publication still works: the restored firing rule receives zero and publishes a recovery despite no successful feed check. An expired-pointer alert has the same problem when its pointer cannot be read after restart. Represent unknown inputs explicitly and preserve the persisted alert until sufficient state is known to clear it. Test a cold mirror against a restored firing rule.
+
+3. **P2 — Console email delivery suppresses changes to an active GeoIP reason.**
+ `internal/console/alertwatch.go:201,243`: delivery deduplication compares only `Firing`. After an operator receives reason 2, the newly emitted reason-3 event updates the bucket, but the watcher sees `true → true` and sends no expiry notification. Remember the delivered GeoIP reason per recipient, or an equivalent transition identifier, while retaining retry behavior. Test reason 2 → 3 through `alertPass`, beyond the evaluator test.
+
+4. **P2 — Region guesses can carry another generation’s attribution.**
+ `internal/console/regionpicker.go:45–49`: `guessRegion` performs its lookup before `geoipAttribution` independently reads the current source. If the follower swaps DB-IP to GeoLite2 between those calls, the page credits GeoLite2 for a DB-IP-derived suggestion; swapping to nil removes the credit altogether. Return the source alongside the location under the same reader lock and use that source for attribution. Add a test that swaps between lookup and rendering.
+
+Validation was static. Targeted tests could not start because Go’s work directory was read-only; cross-compilation remains unverified.
+
+Verdict: revise.
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R5.md b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R5.md
new file mode 100644
index 000000000..8e3b1b8e6
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R5.md
@@ -0,0 +1,28 @@
+| Previous finding | Disposition at `9cd8e78f` |
+|---|---|
+| R1-1 — Readiness without established database state | **Resolved.** Unknown pointers block readiness; off and expired pointers require empty mirrors. |
+| R1-2 — `off` waits beyond caller timeout | **Resolved.** `TryLock` refuses concurrent publication with 409. |
+| R1-3 — Unbounded console pointer reads | **Resolved for pointer reads.** `readPointerCapped` streams and caps the object. |
+| R1-4 — Recovery generation deleted after failed synchronization | **Resolved.** Installation-error cleanup protects loaded and retained generations; failed synchronization retains all potentially recoverable generations. |
+| R1-5 — Rejected-upload test does not establish zero reads | **Resolved at the reservation boundary.** The panicking-reader test detects reads before slot reservation. |
+| R1-6 — Countries overflow ordering | **Resolved.** `_other` survives sorting and is relabelled during rendering. |
+| R2-1 — Feed status writes bypass the read cap | **Partly resolved; P2 remains.** `internal/console/geoipfeed.go:174` still calls `Put`, and `internal/release/store.go:408` still reads the entire existing object with `os.ReadFile` before checking the write precondition. An oversized `feed.json` can exhaust memory despite the initial capped read. Apply the stat/streamed-hash checks already used by `PutFrom`, and test this underlying write path. |
+| R2-2 — Timed-out checks never persist failures | **Resolved.** Status writes receive a separate bounded context. |
+| R2-3 — Unreadable feed status clears an alert | **Resolved for established state.** Read and parse failures preserve the previous feed state. |
+| R2-4 — Unusable directory prevents “not loaded” alert | **Resolved.** Control-plane state is retained before directory preparation. |
+| R2-5 — Published alert reasons never change while unhealthy | **Resolved for publication.** Reason changes update `Value` and emit an event. |
+| R3-1 — Console follower keeps serving an expired database | **Resolved.** Every tick checks the loaded reader independently of `current`; candidates also receive a metadata expiry check. |
+| R3-2 — Transient status-read failure erases feed history | **Resolved.** An unsuccessful status read prevents replacement of unknown history. |
+| R4-1 — Unbounded bucket operations block startup or publication | **Resolved.** Startup cleanup is asynchronous and bounded; maintenance receives bounded contexts; staging independently bounds its pointer read. |
+| R4-2 — Restart falsely clears a persisted GeoIP alert | **Resolved for the reported unreadable-input cases.** Unknown state produces no metric reading, preserving the restored firing rule. The new guard introduces finding 1 below. |
+| R4-3 — Email delivery suppresses active reason changes | **Partly resolved; P2 remains.** `internal/console/alertwatch.go:231–242` permanently records delivered reasons as `geoip@N=true`. After `2 → 3 → 0 → 2 → 3`, the second expiry notification is suppressed. Returning to a previously delivered reason within one firing episode is also suppressed. Persist the **last delivered reason per recipient**, preserving retries. Extend the test beyond its current `2 → 3 → 0` sequence. |
+| R4-4 — Region guesses carry another generation’s attribution | **Resolved.** `recordingLocator` uses `Swappable.LocateFrom` to capture the lookup and its source under the same reader lock. |
+
+New findings:
+
+1. **P2 — Unknown feed state suppresses an independently established expiry alert.**
+ `internal/edge/geoipmirror.go:554–557`: `AlertCode` returns no reading until both pointer and feed state are known, before checking any affirmative reason. On cold boot with a readable expired pointer and malformed or unreadable `feed.json`, polling confirms expiry and removes the database, but reason 3 never reaches the evaluator—even after its 30-minute sustain. An unrelated feed-status failure therefore hides a confirmed database failure indefinitely. Evaluate established unhealthy conditions before withholding an unknown reading; require sufficient knowledge before reporting recovery. Add a cold-start test combining an expired pointer with unreadable feed status and drive it through the evaluator.
+
+Validation was static. Targeted tests could not start because Go’s work directory was read-only; cross-compilation remains unverified.
+
+Verdict: revise.
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R6.md b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R6.md
new file mode 100644
index 000000000..9c3074a20
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R6.md
@@ -0,0 +1,29 @@
+| Previous finding | Disposition at `c24b64d2` |
+|---|---|
+| R1-1 — Readiness without established database state | **Resolved.** Unknown pointers block readiness; off and expired pointers require empty mirrors. |
+| R1-2 — `off` waits behind publication | **Resolved.** `TryLock` returns 409 during publication. |
+| R1-3 — Unbounded console pointer reads | **Resolved for reads.** Pointer reads are streamed and capped. |
+| R1-4 — Recovery generation deleted after failed synchronization | **Resolved for the reported installation paths.** Cleanup preserves loaded and retained generations until synchronization succeeds. |
+| R1-5 — Rejected-upload test does not establish zero reads | **Resolved at the reservation boundary.** The panicking-reader test detects reads before slot reservation. |
+| R1-6 — Countries overflow ordering | **Resolved.** `_other` survives sorting and is relabelled during rendering. |
+| R2-1 — Feed status writes bypass the read cap | **Partly resolved; P2 remains.** `internal/console/geoipfeed.go:174` still calls `Put`; `internal/release/store.go:408` reads the entire existing object before checking its precondition. An oversized `feed.json` can still exhaust memory. Use the stat/streamed-hash checks implemented by `PutFrom`, and test the underlying write path. |
+| R2-2 — Timed-out checks never persist failures | **Resolved.** Status persistence receives a separate bounded context. |
+| R2-3 — Unreadable feed status clears an alert | **Resolved.** Read and parse failures preserve established feed state. |
+| R2-4 — Unusable directory prevents “not loaded” alert | **Resolved.** Control-plane state is retained before directory preparation. |
+| R2-5 — Published alert reasons never change while unhealthy | **Resolved.** Reason changes update `Value` and emit an event. |
+| R3-1 — Console follower keeps serving an expired database | **Resolved.** Each tick checks the loaded reader independently; candidate metadata is also checked. |
+| R3-2 — Transient status-read failure erases feed history | **Resolved.** Unknown history is not overwritten. |
+| R4-1 — Unbounded bucket operations block startup or publication | **Resolved.** Startup cleanup is asynchronous; maintenance and staging reads have deadlines. |
+| R4-2 — Restart falsely clears a persisted GeoIP alert | **Resolved.** Unknown inputs cannot establish recovery. |
+| R4-3 — Email delivery suppresses active reason changes | **Resolved for the reported sequences.** Successful delivery resets the other reason markers; tests cover repeated episodes and returning to an earlier reason. |
+| R4-4 — Region guesses carry another generation’s attribution | **Resolved.** Location and source are captured under the same reader lock. |
+| R5-1 — Unknown feed state suppresses confirmed expiry | **Resolved.** Established unhealthy conditions are evaluated before the unknown-state guard; the regression test includes evaluator sustain. |
+
+New findings:
+
+1. **P2 — A failed startup deletion leaves a disabled feed’s alert firing indefinitely.**
+ [internal/console/server.go:1445](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/server.go:1445), [internal/console/geoipfeed.go:365](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/geoipfeed.go:365): unset `CONSOLE_GEOIP_FEED` and restart while `feed.json` records seven failures. If the startup deletion fails or times out, `clearFeedStatus` only logs the failure. Nothing retries deletion: the feeder is disabled, and maintenance sweeps only database blobs. After bucket access recovers, the console box continues reading the old failing status and reporting reason 5 indefinitely, contrary to §10.4’s clearing rule. Retry deletion with bounded attempts and backoff until successful or cancelled. Test a failed deletion followed by recovery without another restart.
+
+Validation was static. Targeted tests could not start because Go’s work directory is read-only; cross-compilation remains unverified.
+
+Verdict: revise.
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R7.md b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R7.md
new file mode 100644
index 000000000..0df64ee2e
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R7.md
@@ -0,0 +1,33 @@
+| Previous finding | Disposition at `aaa1434e` |
+|---|---|
+| R1-1 — Readiness without established database state | **Resolved.** Unknown pointers block readiness; off and expired pointers require empty mirrors. |
+| R1-2 — `off` waits behind publication | **Resolved.** `TryLock` returns 409 while publication holds the slot. |
+| R1-3 — Unbounded console pointer reads | **Resolved for reads.** Pointer reads are streamed and capped. |
+| R1-4 — Recovery generation deleted after failed synchronization | **Resolved for the reported installation paths.** Loaded and retained generations survive installation failures and unsuccessful synchronization. |
+| R1-5 — Rejected-upload test does not establish zero reads | **Resolved at the reservation boundary.** The panicking-reader test detects reads before reservation. |
+| R1-6 — Countries overflow ordering | **Resolved.** `_other` remains intact until rendering and sorts last. |
+| R2-1 — Feed status writes bypass the read cap | **Partly resolved; P2 remains.** `geoipfeed.go:174` still calls `Put`; `release/store.go:408` reads the entire existing object before checking its precondition. An oversized `feed.json` can still exhaust memory. Apply the stat/streamed-hash precondition checks used by `PutFrom`. |
+| R2-2 — Timed-out checks never persist failures | **Resolved.** Status persistence gets a separate bounded context. |
+| R2-3 — Unreadable feed status clears an alert | **Resolved.** Read and parse failures preserve established feed state. |
+| R2-4 — Unusable directory prevents “not loaded” alert | **Resolved.** Pointer state is retained before directory preparation. |
+| R2-5 — Published alert reasons never change while unhealthy | **Resolved.** Reason changes update `Value` and emit an event. |
+| R3-1 — Console follower keeps serving an expired database | **Resolved.** Loaded-reader expiry is checked independently of local state and candidate loading. |
+| R3-2 — Transient status-read failure erases feed history | **Resolved.** Unknown history is not overwritten. |
+| R4-1 — Unbounded bucket operations block startup or publication | **Resolved for the reported paths.** Startup cleanup is asynchronous; maintenance and staging reads have deadlines. |
+| R4-2 — Restart falsely clears a persisted GeoIP alert | **Resolved.** Unknown inputs cannot establish recovery. |
+| R4-3 — Email delivery suppresses active reason changes | **Resolved.** Successful delivery records the last reason per recipient and resets other reason markers. |
+| R4-4 — Region guesses carry another generation’s attribution | **Resolved.** Location and source are captured under one reader lock. |
+| R5-1 — Unknown feed state suppresses confirmed expiry | **Resolved.** Established unhealthy conditions are evaluated before the unknown-state guard. |
+| R6-1 — Failed startup deletion leaves a disabled feed alert firing indefinitely | **Resolved.** Deletion retries with bounded attempts and capped exponential backoff until success or cancellation. The regression test covers recovery without restart. |
+
+New findings:
+
+1. **P2 — Unbounded feed edition metadata can make both control records unreadable.**
+ [internal/console/geoipfeed.go:244](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/geoipfeed.go:244), [internal/console/geoippublish.go:263](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/geoippublish.go:263): `Last-Modified` is checked only for emptiness, then copied into the published pointer and feed status. A malformed provider or redirect response containing a 70 KiB value, followed by an otherwise valid archive and checksum, produces records exceeding their 64 KiB read caps. Publication succeeds and sweeps the previous blob, but mirrors cannot read the new pointer and feed-status readers reject the new history. Validate and bound the edition before publication, and enforce serialized control-record limits before writing. Test oversized metadata while a usable generation is published; refusal must preserve that generation.
+
+2. **P2 — Successful unchanged-edition checks can leave the feed reported as “not run yet.”**
+ [internal/console/geoipfeed.go:114](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/geoipfeed.go:114): disable the feed, allowing startup cleanup to delete `feed.json`, then re-enable it while its current edition remains published. Both providers’ unchanged-edition paths return an empty edition. `Check` clears failure counters but never sets `LastSuccess` or `LastEdition`, so `carlos system geoip status` reports “not run yet” despite successful daily checks, potentially for the rest of the DB-IP month. Return the confirmed current edition and record successful checks consistently. Test re-enabling with an existing pointer and absent feed status.
+
+Validation was static. Targeted tests could not start because Go’s work directory is read-only; cross-compilation remains unverified.
+
+Verdict: revise.
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R8.md b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R8.md
new file mode 100644
index 000000000..2ec7fdc0b
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R8.md
@@ -0,0 +1,34 @@
+| Previous finding | Disposition at `29455f24` |
+|---|---|
+| R1-1 — Readiness without established database state | **Resolved.** Unknown pointers block readiness; off and expired pointers require empty mirrors. |
+| R1-2 — `off` waits behind publication | **Resolved.** `TryLock` returns 409 while publication holds the slot. |
+| R1-3 — Unbounded console pointer reads | **Resolved for reads.** Pointer reads are streamed and capped. |
+| R1-4 — Recovery generation deleted after failed synchronization | **Resolved for the reported paths.** Installation failures preserve loaded and retained generations. |
+| R1-5 — Rejected-upload test does not establish zero reads | **Resolved at the reservation boundary.** The panicking-reader test detects reads before reservation. |
+| R1-6 — Countries overflow ordering | **Resolved.** `_other` remains intact until rendering and sorts last. |
+| R2-1 — Feed status writes bypass the read cap | **Partly resolved; P2 remains.** `geoipfeed.go:174` still calls `Put`; `release/store.go:408` reads the entire existing object before checking its precondition. An oversized `feed.json` can still exhaust memory. Apply the stat/streamed-hash checks used by `PutFrom`. |
+| R2-2 — Timed-out checks never persist failures | **Resolved for download timeouts.** Status persistence gets a separate bounded context. |
+| R2-3 — Unreadable feed status clears an alert | **Resolved.** Read and parse failures preserve established feed state. |
+| R2-4 — Unusable directory prevents “not loaded” alert | **Resolved.** Pointer state is retained before directory preparation. |
+| R2-5 — Published alert reasons never change while unhealthy | **Resolved.** Reason changes update `Value` and emit an event. |
+| R3-1 — Console follower keeps serving an expired database | **Resolved.** Loaded-reader expiry is checked independently of local state and candidate loading. |
+| R3-2 — Transient status-read failure erases feed history | **Resolved.** Unknown history is not overwritten. |
+| R4-1 — Unbounded bucket operations block startup or publication | **Resolved for the reported bucket operations.** Startup cleanup is asynchronous; maintenance and staging reads have deadlines. |
+| R4-2 — Restart falsely clears a persisted GeoIP alert | **Resolved.** Unknown inputs cannot establish recovery. |
+| R4-3 — Email delivery suppresses active reason changes | **Resolved.** Successful delivery records the last reason per recipient and resets other reason markers. |
+| R4-4 — Region guesses carry another generation’s attribution | **Resolved.** Location and source are captured under one reader lock. |
+| R5-1 — Unknown feed state suppresses confirmed expiry | **Resolved.** Established unhealthy conditions are evaluated before the unknown-state guard. |
+| R6-1 — Failed startup deletion leaves a disabled feed alert firing indefinitely | **Resolved.** Deletion retries with bounded attempts and capped exponential backoff. |
+| R7-1 — Unbounded feed edition metadata makes control records unreadable | **Resolved for the reported input.** MaxMind editions must parse as HTTP dates and are normalized; publication rejects oversized serialized pointers. |
+| R7-2 — Unchanged-edition checks leave the feed reported as “not run yet” | **Resolved.** Both providers return the confirmed edition, allowing `Check` to record success and edition history. |
+
+New findings:
+
+1. **P1 — MMDB verification can indefinitely hold the publish slot despite its deadline.**
+ [internal/console/geoippublish.go:216](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/geoippublish.go:216), [internal/geo/mmdb.go:41](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/geo/mmdb.go:41): `Commit` calls `OpenMMDB` synchronously while holding the publication mutex. The pinned `maxminddb-golang` verifier enumerates every network path without checking a context or limiting traversal work. A compact IPv6 database containing 128 nodes, with both children of each node pointing to the next and the final children pointing to one data record, causes enumeration of \(2^{128}\) paths. It passes the byte caps; verification runs before the country, type and shrink checks.
+
+ A hostile feed response—or an operator uploading such a downloaded file—therefore leaves verification consuming CPU, publication and `off` returning 409, and staging retained beyond the 15-minute deadline. For the feed, `Check` never returns to persist a failure or schedule another check. Bound verification itself through a cancellable, work-limited verifier or a killable worker process. A timeout around an uncancelled goroutine is insufficient. Add a pathological-tree regression proving timely termination, slot release, staging cleanup and preservation of the previous pointer.
+
+Validation was static, including inspection of the pinned verifier. Targeted Go tests could not start because the work directory is read-only; cross-compilation remains unverified.
+
+Verdict: revise.
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R9.md b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R9.md
new file mode 100644
index 000000000..d501cec78
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-GEOIP-CODE1-R9.md
@@ -0,0 +1,39 @@
+| Previous finding | Disposition at `ad70f841` |
+|---|---|
+| R1-1 — Readiness without established database state | **Resolved.** Unknown pointers block readiness; off and expired pointers require empty mirrors. |
+| R1-2 — `off` waits behind publication | **Resolved.** `TryLock` returns 409 while publication holds the slot. |
+| R1-3 — Unbounded console pointer reads | **Resolved for reads.** Pointer reads are streamed and capped. |
+| R1-4 — Recovery generation deleted after failed synchronization | **Resolved for the reported paths.** Installation failures preserve loaded and retained generations. |
+| R1-5 — Rejected-upload test does not establish zero reads | **Resolved at the reservation boundary.** The panicking-reader test detects reads before reservation. |
+| R1-6 — Countries overflow ordering | **Resolved.** `_other` remains intact until rendering and sorts last. |
+| R2-1 — Feed status writes bypass the read cap | **Partly resolved; P2 remains.** `geoipfeed.go:174` still calls `Put`; `release/store.go:408` reads the entire existing object before checking its precondition. An oversized `feed.json` can exhaust memory. Apply the stat/streamed-hash checks used by `PutFrom`. |
+| R2-2 — Timed-out checks never persist failures | **Resolved for download timeouts.** Status persistence gets a separate bounded context. |
+| R2-3 — Unreadable feed status clears an alert | **Resolved.** Read and parse failures preserve established feed state. |
+| R2-4 — Unusable directory prevents “not loaded” alert | **Resolved.** Pointer state is retained before directory preparation. |
+| R2-5 — Published alert reasons never change while unhealthy | **Resolved.** Reason changes update `Value` and emit an event. |
+| R3-1 — Console follower keeps serving an expired database | **Resolved.** Loaded-reader expiry is checked independently of local state and candidate loading. |
+| R3-2 — Transient status-read failure erases feed history | **Resolved.** Unknown history is not overwritten. |
+| R4-1 — Unbounded bucket operations block startup or publication | **Resolved for the reported bucket operations.** Startup cleanup is asynchronous; maintenance and staging reads have deadlines. |
+| R4-2 — Restart falsely clears a persisted GeoIP alert | **Resolved.** Unknown inputs cannot establish recovery. |
+| R4-3 — Email delivery suppresses active reason changes | **Resolved.** Successful delivery records the last reason per recipient and resets other reason markers. |
+| R4-4 — Region guesses carry another generation’s attribution | **Resolved.** Location and source are captured under one reader lock. |
+| R5-1 — Unknown feed state suppresses confirmed expiry | **Resolved.** Established unhealthy conditions are evaluated before the unknown-state guard. |
+| R6-1 — Failed startup deletion leaves a disabled feed alert firing indefinitely | **Resolved.** Deletion retries with bounded attempts and capped exponential backoff. |
+| R7-1 — Unbounded feed edition metadata makes control records unreadable | **Resolved for the reported input.** MaxMind editions are normalized HTTP dates; publication rejects oversized serialized pointers. |
+| R7-2 — Unchanged-edition checks leave the feed reported as “not run yet” | **Resolved.** Both providers return the confirmed edition, allowing success and edition history to be recorded. |
+| R8-1 — MMDB verification indefinitely holds the publish slot | **Partly resolved; P1 remains.** At `internal/geo/mmdb.go:74`, the budget and cancellation checks run only after `Networks.Next()` returns a network. The dependency traverses empty branches internally without returning. A 128-node IPv6 DAG whose children share the next node and terminate at the `NodeCount` empty sentinel makes the **first** `Next()` traverse \(2^{128}\) empty paths. Publication remains stuck beyond its deadline, blocking `off` and retaining staging. Bound internal traversal steps, including empty branches, or use a killable verifier process; add this variant to the regression tests. |
+
+New findings:
+
+1. **P1 — Malformed MMDB metadata can crash the entire console.**
+ [internal/geo/mmdb.go:55](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/geo/mmdb.go:55), [internal/console/apisystemgeoip.go:84](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/apisystemgeoip.go:84)
+
+ On a 64-bit host, a small MMDB can encode `node_count = 2^63`, `record_size = 24`, and `ip_version = 6`. The pinned library accepts the metadata, then its unchecked `NodeCount * RecordSize / 4` calculation wraps to zero. Its IPv4-subtree initialization indexes the resulting empty node buffer and panics, before either verification or the country/type checks.
+
+ A hostile feed response or downloaded operator upload therefore terminates the console process: both feed processing and publication run in goroutines without panic recovery. HTTP handler recovery cannot catch the publication goroutine’s panic.
+
+ Validate metadata arithmetic and bounds before the unsafe parser runs, or isolate parsing and verification in a killable worker whose crash becomes a refused publication. Any recovery-based solution must also release the mapping allocated before parsing. Add a regression asserting process survival, slot release, staging cleanup, and preservation of the previous pointer.
+
+Validation was static, including inspection of the pinned parser and iterator. Targeted Go tests could not start because creating the build work directory failed with “read-only file system”; cross-compilation remains unverified.
+
+Verdict: revise.
diff --git a/internal/release/putfrom_test.go b/internal/release/putfrom_test.go
index 1c2bfc4f0..f50d6b130 100644
--- a/internal/release/putfrom_test.go
+++ b/internal/release/putfrom_test.go
@@ -5,6 +5,8 @@ import (
"context"
"errors"
"io"
+ "os"
+ "runtime"
"strings"
"testing"
)
@@ -70,3 +72,46 @@ func (zeroReaderAt) ReadAt(p []byte, _ int64) (int, error) {
}
return len(p), nil
}
+
+// Put's CAS gate must not read the existing object into memory: the feed
+// status write runs Put over whatever sits at the key, and an oversized
+// object there would otherwise be loaded whole (R2-1). A sparse 256 MiB
+// file stands in for it; ReadFile would allocate all of it.
+func TestDirStorePutGateDoesNotLoadExistingObject(t *testing.T) {
+ ctx := context.Background()
+ root := t.TempDir()
+ d := NewDirStore(root)
+ if _, err := d.Put(ctx, "big.json", []byte("x"), ""); err != nil {
+ t.Fatal(err)
+ }
+ p, err := d.path("big.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ const size = 256 << 20
+ if err := os.Truncate(p, size); err != nil {
+ t.Fatal(err)
+ }
+ var before, after runtime.MemStats
+ runtime.ReadMemStats(&before)
+ if _, err := d.Put(ctx, "big.json", []byte("y"), ""); !errors.Is(err, ErrCASConflict) {
+ t.Fatalf("create-only over an existing key = %v, want ErrCASConflict", err)
+ }
+ if _, err := d.Put(ctx, "big.json", []byte("y"), "not-the-etag"); !errors.Is(err, ErrCASConflict) {
+ t.Fatalf("replace with a stale etag = %v, want ErrCASConflict", err)
+ }
+ runtime.ReadMemStats(&after)
+ if grew := after.TotalAlloc - before.TotalAlloc; grew > 4<<20 {
+ t.Fatalf("Put's gate allocated %d bytes over a %d-byte object; it must stream", grew, size)
+ }
+ cur, err := hashFile(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := d.Put(ctx, "big.json", []byte("small"), cur); err != nil {
+ t.Fatalf("replace with the right etag: %v", err)
+ }
+ if got, _, err := d.Get(ctx, "big.json"); err != nil || string(got) != "small" {
+ t.Fatalf("Get = (%q, %v), want small", got, err)
+ }
+}
diff --git a/internal/release/store.go b/internal/release/store.go
index 5607a6a8c..57449cf47 100644
--- a/internal/release/store.go
+++ b/internal/release/store.go
@@ -405,18 +405,28 @@ func (d *DirStore) Put(_ context.Context, key string, body []byte, ifMatch strin
}
d.mu.Lock()
defer d.mu.Unlock()
- cur, err := os.ReadFile(p)
+ // The CAS gate, mirroring S3's semantics exactly: "" = create-only,
+ // non-empty = the object must still carry that etag. Stat and a
+ // streamed hash, as in PutFrom, so an oversized object at the key is
+ // never read into memory.
+ _, err = os.Stat(p)
exists := err == nil
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return "", err
}
- // The CAS gate, mirroring S3's semantics exactly: "" = create-only,
- // non-empty = the object must still carry that etag.
if ifMatch == "" && exists {
return "", fmt.Errorf("dirstore: %s exists: %w", key, ErrCASConflict)
}
- if ifMatch != "" && (!exists || etagOf(cur) != ifMatch) {
- return "", fmt.Errorf("dirstore: %s moved on: %w", key, ErrCASConflict)
+ if ifMatch != "" {
+ cur := ""
+ if exists {
+ if cur, err = hashFile(p); err != nil {
+ return "", err
+ }
+ }
+ if !exists || cur != ifMatch {
+ return "", fmt.Errorf("dirstore: %s moved on: %w", key, ErrCASConflict)
+ }
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return "", err