# Platform GeoIP, plan 2: the feed and the `geoip` alert

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (this plan is executed natively, as plan 1 was). Steps use checkbox (`- [ ]`) syntax.

**Goal:** The console keeps the published database current by itself, fetching DB-IP Lite or GeoLite2. Every box raises a `geoip` alert, through the existing box-alert pipeline, when its database is stale, expiring, expired or not loaded, or (on the console's box only) when the feed keeps failing.

**Architecture:**
- **The fetcher** lives in the console (`internal/console/geoipfeed.go`). It is the one control-plane process, so there is only one fetcher. It reuses plan 1's publish slot (`geoipPublisher.Stage`/`Commit`), adding two feed-only rules: the type must match the configured feed, and an edition must be newer than the last one from the same feed.
- **Archives** are opened by pure, capped readers in `internal/geoip/archive.go`.
- **The feed's state** goes to `control/geoip/feed.json` after every check, naming the console's box.
- **The alert** is a fifth box rule beside disk, mem, cpu and lag. Each mirror computes a reason code. `metrics.Evaluate` applies its sustain, and the host's alert sink and the console's alert watcher deliver it, unchanged.

**Tech Stack:** Go stdlib (`compress/gzip`, `archive/tar`, `net/http`), plan 1's packages.

**Spec:** `docs/superpowers/specs/2026-10-06-platform-geoip-design.md`, §10 (rev 11 or later). Plan 1 is built on this branch; its code is the base.

## Global Constraints

- **Console env:**
  - `CONSOLE_GEOIP_FEED`: one of `dbip-city-lite`, `dbip-country-lite`, `geolite2-city`, `geolite2-country`;
  - `CONSOLE_GEOIP_MAXMIND_ACCOUNT` and `CONSOLE_GEOIP_MAXMIND_KEY`.
  - An unknown name, or GeoLite2 without both credentials, is logged once at startup, and the feed stays off.
- **Feed name to type:**
  - `dbip-city-lite` → `DBIP-City-Lite`
  - `dbip-country-lite` → `DBIP-Country-Lite`
  - `geolite2-city` → `GeoLite2-City`
  - `geolite2-country` → `GeoLite2-Country`
- **URLs:**
  - DB-IP: `https://download.db-ip.com/free/dbip-<city|country>-lite-YYYY-MM.mmdb.gz`
  - MaxMind: `https://download.maxmind.com/geoip/databases/GeoLite2-<City|Country>/download?suffix=tar.gz`, with `suffix=tar.gz.sha256` for the checksum.
- **Caps:**
  - compressed download 512 MiB, enforced while copying;
  - all expanded tar bytes 1.5 GiB, skipped members included;
  - the `.mmdb` itself 1 GiB (plan 1's `copyCapped`);
  - checksum file 4 KiB;
  - `feed.json` 64 KiB;
  - each check under the publisher's 15-minute deadline.
- **Redirects:** at most 3, `https` only. The Basic `Authorization` header is dropped on a cross-host redirect. The key never appears in a URL or a log line.
- **Schedule:** a first check 5 minutes after start, then daily with up to an hour of jitter.
- **Seven** consecutive 404/410 answers raise the alert's feed reason.
- **Alert codes:** the rule's `Value` is the reason code; 0 = none.

  | Code | Reason |
  |---|---|
  | 1 | loaded database older than 45 days |
  | 2 | GeoLite2 within 9 days of D1 expiry (21 days old) |
  | 3 | the pointer is expired, marked or by the clock |
  | 4 | a live pointer has not been loaded for over an hour |
  | 5 | the feed has failed 7 times in a row (console box only) |

  When several hold at once, the most severe wins: 3, then 4, then 2, then 1, then 5.
- **The rule** fires after the reason has held for 30 minutes (`Rules.GeoIPSustain`). It clears at once when the code returns to 0.
- **Copy:** every new alert sentence and log line follows the house voice (no em dashes). The alert sentences join plan 1's copy review.

## Review Focus

1. **A feed that serves a different product, an older edition, or a truncated file** must leave the published database untouched.
2. **A redirect** to another host must never carry the MaxMind credentials.
3. **A decompression bomb, or a tar padded with junk members,** must stop at the caps without filling the disk.
4. **The feed reason** must fire on exactly one box, the console's, and must still fire on a fresh deployment where the feed has never succeeded.
5. **`off`** must clear every pointer-related reason; expiry must not.

---

### Task 1: `internal/geoip/archive.go` and `feed.go`

**Files:** Create `internal/geoip/archive.go`, `internal/geoip/feed.go`, `internal/geoip/archive_test.go`, `internal/geoip/feed_test.go`.

**Produces:**

```go
// feed.go
type FeedSpec struct{ Name, Type, Provider string } // Provider: "dbip" | "maxmind"
func ParseFeed(name string) (FeedSpec, bool)
func DBIPURL(spec FeedSpec, month time.Time) string
func MaxMindURL(spec FeedSpec, suffix string) string
type FeedStatus struct {
	V                   int       `json:"v"`
	Feed                string    `json:"feed"`
	Box                 string    `json:"box"`          // the console's box label: the one box that alerts on feed failure
	LastCheck           time.Time `json:"last_check"`
	LastSuccess         time.Time `json:"last_success,omitempty"`
	LastEdition         string    `json:"last_edition,omitempty"`
	ConsecutiveFailures int       `json:"consecutive_failures"`
	LastFailure         string    `json:"last_failure,omitempty"` // a fixed category, never an error text
	ConsecutiveNotFound int       `json:"consecutive_not_found"`
}
const FeedNotFoundAlert = 7
func ParseFeedStatus(raw []byte) (FeedStatus, error)
func (s FeedStatus) Failing() bool // ConsecutiveFailures >= 7 || ConsecutiveNotFound >= 7

// archive.go
var ErrTooLarge = errors.New("geoip archive: larger than its cap")
func CappedReader(r io.Reader, max int64) io.Reader        // returns ErrTooLarge once more than max bytes are read
func GunzipReader(r io.Reader, maxExpanded int64) (io.Reader, error)
func TarGzMMDB(r io.Reader, maxExpanded int64) (io.Reader, error) // the first regular *.mmdb member; every expanded byte counts
```

- [ ] **Step 1: Tests.**
  - `ParseFeed` returns the four names with their types and providers, and rejects anything else.
  - `DBIPURL` for `dbip-city-lite` in 2026-10 is `https://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz`.
  - `CappedReader` errors with `ErrTooLarge` at max+1 and passes exactly max.
  - `GunzipReader` on a 10 MiB run of zeros gzipped, with a cap of 1 MiB, gives `ErrTooLarge` after reading about 1 MiB, never the full expansion.
  - `TarGzMMDB` returns the bytes of `db.mmdb` from an archive whose first member is a 2 MiB `README`, and errors with `ErrTooLarge` when the cap is 1 MiB.
  - `TarGzMMDB` on an archive with no `.mmdb` member gives a clear error.
  - `FeedStatus` round-trips through JSON.
  - `Failing` crosses at exactly 7.
  - Build the test archives in memory with `archive/tar` and `compress/gzip`.
- [ ] **Step 2: Implement.**
  - `CappedReader` counts bytes, and once the count exceeds max it returns `ErrTooLarge`.
  - `GunzipReader` is `CappedReader(gzip.NewReader(r), maxExpanded)`.
  - `TarGzMMDB` wraps the gunzipped stream in one `CappedReader(…, maxExpanded)`, so skipped members count. It walks `tar.NewReader` to the first `TypeReg` member whose name ends `.mmdb` (path base) and returns that member's reader.
- [ ] **Step 3:** `go test ./internal/geoip`, then commit.

### Task 2: publish rules for the feed

**Files:** Modify `internal/console/geoippublish.go`, `geoippublish_test.go`.

**Produces:** `geoipPublishOpts` gains `RequireType string` (refuse unless `db.Source()` equals it) and `Monotonic bool`. With `Monotonic` set, the publish is refused when the current pointer has the same `Origin` and the candidate's `BuildEpoch` is not after the current one's. The feed sets both and never sets `Force`.

- [ ] **Step 1: Tests.**
  - `RequireType: "GeoIP2-Country"` refuses the City fixture with the category "the feed returned a GeoIP2-City database, not GeoIP2-Country".
  - With `Monotonic`, `Origin: "feed:x"`, publishing the City fixture twice refuses the second as "not newer than the published edition". The pointer is unchanged.
  - With `Monotonic` against a current pointer whose `Origin` is `manual`, the publish proceeds.
- [ ] **Step 2: Implement it in `Commit`,** after the country probe and before the shrink check.
- [ ] **Step 3:** Run the tests, then commit.

### Task 3: the feed

**Files:** Create `internal/console/geoipfeed.go` and `geoipfeed_test.go`. Modify `internal/console/server.go` (Config `GeoipFeed`, `GeoipMaxMindAccount`, `GeoipMaxMindKey` and `BoxLabel`; start the feed from `StartGeoipJobs`), `cmd/console/main.go` (read the env; `BoxLabel: status.Label(<machine id>)`, reading `/etc/machine-id` the way `internal/host/probes.go` does).

**Produces:**

```go
type geoipFeeder struct {
	pub         *geoipPublisher
	store       release.Store
	spec        geoip.FeedSpec
	account, key string
	box         string
	client      *http.Client // CheckRedirect: geoipFeedRedirect
	now         func() time.Time
	dbipBase, maxmindBase string // test seams; "" = the real hosts
}
func newGeoipFeeder(cfg Config, pub *geoipPublisher) (*geoipFeeder, bool) // false = no feed (unset or misconfigured, logged once)
func (f *geoipFeeder) Check(ctx context.Context) error                   // one check; always writes feed.json
func (f *geoipFeeder) run(ctx context.Context)
func geoipFeedRedirect(req *http.Request, via []*http.Request) error      // ≤3, https only, drops Authorization cross-host
```

`Check`:
1. Read the pointer and `feed.json` (capped).
2. **DB-IP.**
   - The edition is this month's `YYYY-MM`. If the pointer's `Origin` is this feed and its `Edition` is this month, the check is done (a success, nothing to do).
   - Otherwise GET this month's URL, and on a 404 last month's. Only when there is no pointer, or the pointer came from elsewhere, is last month tried, because a pointer from this feed already holds last month.
   - A 404 or 410 on every candidate is a "not out yet" check. `ConsecutiveNotFound` goes up by one; it is not a failure.
3. **MaxMind.**
   - HEAD the archive URL with Basic auth. If `Last-Modified` equals the pointer's `Edition` (same feed origin), the check is done.
   - Otherwise GET `suffix=tar.gz.sha256`: the first 64 hex characters, capped at 4 KiB.
   - Then GET the archive.
4. **Stream the body:**
   - `CappedReader(body, 512 MiB)`, teed into a sha256 hasher;
   - then `GunzipReader` (DB-IP) or `TarGzMMDB` (MaxMind), capped at 1.5 GiB;
   - then `pub.Stage`. `errGeoipBusy` skips this check; it is not a failure.
5. **MaxMind checksum:** drain the rest of the archive through the tee (capped), and compare the archive's sha256 with the published one. On a mismatch, `Abandon` and record the failure "checksum".
6. **Commit** with `geoipPublishOpts{Origin: "feed:" + name, Edition: edition, RequireType: spec.Type, Monotonic: true}`.
7. **Record the outcome.** Success sets `LastSuccess` and `LastEdition`, and zeroes both counters. A refusal or error increments `ConsecutiveFailures` and sets `LastFailure` to a fixed category:
   - `download` / `not found` / `too large` / `checksum` / `invalid` / `wrong type` / `not newer` / `expired` / `store`.
8. **Write `feed.json`** with `Box: f.box`, on every path.

`run`: wait 5 minutes, then check; then wait 24 hours plus up to 1 hour of jitter, and check again. If `CONSOLE_GEOIP_FEED` is unset at startup, delete any `feed.json`, so a switched-off feed alerts nobody.

- [ ] **Step 1: Tests,** against an `httptest.NewTLSServer` standing in for both providers. Its client is the server's client, wrapped with `geoipFeedRedirect`. Use `dbipBase`/`maxmindBase` pointing at it, and the gzip/tar.gz of the GeoIP2 fixtures. Feed types are matched through `RequireType`, so use a `FeedSpec{Type: "GeoIP2-City"}` built by hand:
  - **DB-IP:** a new edition is published, `feed.json` says success, and the edition is this month. A second check makes no GET at all, because it's the same month and the same origin.
  - **404 handling:** this month 404s and last month serves; with no pointer, last month is published. Every candidate 404s and `ConsecutiveNotFound` goes up by one each check; after 7, `FeedStatus.Failing()` is true.
  - **MaxMind:**
    - The HEAD with an unchanged `Last-Modified` makes no GET.
    - A changed one fetches `.sha256` and then the archive, and publishes.
    - A wrong `.sha256` records "checksum", and the pointer is unchanged.
    - The test server asserts that every request carries Basic auth, and that the key appears in no URL.
  - **Redirect:** the archive URL redirects to a second `httptest` TLS server, on another host. That server sees no `Authorization` header. A 4th redirect is refused.
  - **Bomb:** a gzip of 2 GiB of zeros (generate it streamed) is refused as "too large", with the staging directory empty afterwards. For speed, make the expanded cap a field set to 1 MiB in the test.
  - **A feed type mismatch** is refused as "wrong type", even with no pointer at all (the first publish).
  - **A busy slot** (`pub.mu` held) skips the check without counting a failure.
- [ ] **Step 2:** Implement it.
- [ ] **Step 3:** Run the tests, then commit.

### Task 4: the mirror's alert code and `feed.json`

**Files:** Modify `internal/edge/geoipmirror.go`, `internal/edge/geoipmirror_test.go`.

**Produces:**
- `geoMirror` tracks `wantSince` (when the pointer first named a sha that isn't loaded) and `pointerExpired`. It also reads `feed.json` (capped, best effort) on each poll, keeping `feedFailing` and `feedBox`.
- `func (m *geoMirror) AlertCode(now time.Time, box string) float64` implements the priority above.
- `func (e *Edge) GeoIPAlertCode(now time.Time) float64` uses the edge's box label (`WithBoxLabel`). It returns 0 when there is no mirror.

- [ ] **Step 1: Tests:**
  - each code from a constructed state;
  - code 4 only after an hour;
  - code 5 only when `feed.json`'s box equals the edge's label, and with no pointer at all;
  - code 3 for an expired pointer;
  - `off` (no pointer) gives 0 even with a feed failing elsewhere;
  - the priority when 2 and 4 both hold.
- [ ] **Step 2:** Implement it.
- [ ] **Step 3:** Run the tests, then commit.

### Task 5: the `geoip` rule

**Files:** Modify:
- `internal/metrics/alert.go`: `Observation.GeoIP *float64`; `Rules.GeoIPSustain` (default 30 minutes); `evalFrac("geoip", obs.GeoIP, 0.5, r.GeoIPSustain)`;
- `internal/host/metrics_loop.go`: fill `obs.GeoIP` from a published source, the same pattern as `geoIPSource`;
- `internal/host/agent.go`: publish `e.GeoIPAlertCode`;
- `internal/host/alertsink.go`: the `geoip` cases.

Alert sentences, sent to copy review with plan 1's batch:

| Code | Firing | Clearing |
|---|---|---|
| 1 | `the geoip database on %s is more than 45 days old` | `geoip on %s is back to normal` |
| 2 | `the GeoLite2 database on %s expires within 9 days` | the same |
| 3 | `the geoip database on %s has expired and was removed` | the same |
| 4 | `%s has not loaded the published geoip database for over an hour` | the same |
| 5 | `the geoip feed has failed 7 times in a row (seen from %s)` | the same |

`alertLevel` has no level for `geoip`, and `alertSustain` returns `GeoIPSustain`.

- [ ] **Step 1: Tests.**
  - `metrics.Evaluate` fires geoip after 30 minutes at code 1 and not before. It clears on the first 0. A nil reading changes nothing.
  - The alert sink renders each code's sentence.
  - The heartbeat-adjacent test: with no edge published, `obs.GeoIP` is nil.
- [ ] **Step 2:** Implement it.
- [ ] **Step 3:** Run the tests outside the sandbox for `internal/host`, then commit.

### Task 6: feed status in `carlos system geoip status`

**Files:** Modify `internal/console/apisystemgeoip.go` (add `feed` from `feed.json`: name, last check, last success, edition, failures, last failure category), `system_geoip.go` (print one line: `feed: <name>, last success <date>` or `feed: <name>, failing (<n> in a row, last: <category>)`), plus tests.

- [ ] **Steps:** test, implement, then commit.

### Final gate

Run the full gate per memory, check the cross-compile, push, and wait for CI. Then an Astra code review of plan 2's diff. Add the new strings to the copy review.
