Skip to content

feat(resilience): universe filter — 193 UN members + 3 SARs (Plan 2026-04-26-002 PR 1 / U2)#3435

Merged
koala73 merged 5 commits into
mainfrom
feat/resilience-universe-filter
Apr 26, 2026
Merged

feat(resilience): universe filter — 193 UN members + 3 SARs (Plan 2026-04-26-002 PR 1 / U2)#3435
koala73 merged 5 commits into
mainfrom
feat/resilience-universe-filter

Conversation

@koala73

@koala73 koala73 commented Apr 26, 2026

Copy link
Copy Markdown
Owner

Summary

PR 1 of plan 2026-04-26-002. Filters the resilience ranking universe from ~222 entries (every ISO2 from any source map) down to 196 entries (193 UN members + 3 SARs: HK, MO, TW). Removes non-sovereign territories (AS, GU, GL, IM, GI, FK, PR, BM, etc.) at universe-build time.

Per Q1 decision (KTD): UN members + 3 SARs. HK/MO/TW remain in the dataset; the future headlineEligible gate (PR 6) can separate them from the headline ranking.

What ships

  • server/worldmonitor/resilience/v1/cohorts/sovereign-status.json — 196-entry whitelist
  • scripts/shared/rankable-universe.mjsisInRankableUniverse(iso2) helper, single source of truth for both seeders
  • scripts/seed-resilience-static.mjs — filter applied at finalizeCountryPayloads
  • scripts/seed-resilience-scores.mjs — defense-in-depth filter on the static index read
  • tests/resilience-universe-filter.test.mts — 15 tests covering whitelist shape, helper, edge cases

Test plan

  • npx tsx --test tests/resilience-universe-filter.test.mts — 15/15 pass
  • npm run test:data — 609/609 resilience tests pass
  • npm run typecheck clean
  • Post-merge: run seed-resilience-static.mjs to verify universe drops from ~222 to ~196 (warning log will report dropped count)
  • Post-merge: PR 0's cohort anti-inversion test (PR feat(resilience): cohort anti-inversion fixture (Plan 2026-04-26-002 PR 0 / U1) #3433) still passes — microstate-territories cohort coverage drops from 12/13 to 9/13 (FK, GL, GI, IM filtered out) but PERMISSIVE thresholds remain valid

Lineage

PR 0 (#3433) shipped the cohort fixture infrastructure. This PR is the first universe/scoring change. Stacked PRs 2-7 follow per the plan's sequencing.

…6-04-26-002 §U2, PR 1)

Lands the rankable-universe whitelist filter at universe-build time.
Earlier behavior: `seed-resilience-static.mjs` admitted every ISO2 from
any source map (~222 entries including AS/GU/GL/IM/GI/FK/PR/etc.).
Post-PR-1: ~196 entries (193 UN members + 3 SARs).

**Decision (per plan KTD Q1)**: Option (b) — UN members + 3 standalone
SARs (HK, MO, TW). The 3-SAR exception preserves their presence in
the dataset; the future `headlineEligible` gate (PR 6) can separate
them from the headline ranking if/when that policy ships.

**Single source of truth.** `server/worldmonitor/resilience/v1/cohorts/sovereign-status.json`
holds the 196-entry whitelist. `scripts/shared/rankable-universe.mjs`
is the helper both seeders consume to ensure their universes match.

**Filter sites (both seeders).**
- `seed-resilience-static.mjs:finalizeCountryPayloads` — drops non-rankable
  entries at universe-build time. Logs `Dropped N non-rankable territory entries`.
- `seed-resilience-scores.mjs` — defense-in-depth filter on the static
  index read, in case the index was seeded by an older version of
  seed-resilience-static during a deploy transient.

**No score-formula changes.** This is a universe-membership change
only. Existing payload shape and per-country score computation
unchanged. The cohort anti-inversion fixture (PR 0 / PR #3433) thresholds
remain PERMISSIVE; PR 3 (coverage penalty) is the next step that
tightens them.

**Testing.**
- `tests/resilience-universe-filter.test.mts` (new, 15 tests):
  - Whitelist shape: 193 UN + 3 SARs = 196, no duplicates
  - SAR cohort = exactly {HK, MO, TW}
  - `isInRankableUniverse` accepts UN members + SARs, rejects territories
    (AS, GU, GL, IM, GI, FK, PR, BM, KY, etc.) and non-UN entities
    (XK Kosovo, PS Palestine, VA Vatican, EH Western Sahara)
  - Case-insensitive iso2 handling
  - Invalid input shapes (empty, ISO3, null, undefined) all return false
- `npm run test:data` — 609/609 resilience tests pass
- `npm run typecheck` clean

**Lineage.** Plan 2026-04-26-002 §U2 (PR 1 of 8). Stacked on PR 0
(#3433) which already shipped the cohort anti-inversion fixture.
This PR's filter directly affects the microstate-territories cohort
in PR 0's fixture (4 of 13 entries — GL, GI, IM, FK — drop out of
the live ranking once this lands), but the PERMISSIVE thresholds
remain valid.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
@vercel

vercel Bot commented Apr 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Apr 26, 2026 4:31pm

Request Review

@greptile-apps

greptile-apps Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Introduces a 196-entry universe whitelist (sovereign-status.json, 193 UN members + 3 SARs) and a isInRankableUniverse helper that both seeders now consume to filter non-sovereign territories at build time, reducing the resilience ranking universe from ~222 to ~196 entries. The implementation is well-structured with good test coverage (15 tests) and a clear defense-in-depth pattern across both seed scripts.

Confidence Score: 4/5

Safe to merge — only P2 observability/style findings; no functional defects in the filter logic or whitelist data.

All findings are P2 (log counter overcounts entries vs. unique territories, no error-message wrapping on the readFileSync IIFE). The core filtering logic, whitelist accuracy, and test coverage are solid.

scripts/seed-resilience-static.mjs — droppedNonRankable counter methodology differs from the companion seeder; scripts/shared/rankable-universe.mjs — readFileSync has no structured error wrapping.

Important Files Changed

Filename Overview
scripts/shared/rankable-universe.mjs New helper module — single source of truth for the rankable universe; logic is correct, two minor P2 observations (no readFileSync error wrapping, missing length guard in getSovereignStatus vs isInRankableUniverse).
scripts/seed-resilience-static.mjs Filter correctly applied at finalizeCountryPayloads; droppedNonRankable counter overcounts (per-dataset-field, not per unique territory), creating log inconsistency vs seed-resilience-scores.
scripts/seed-resilience-scores.mjs Defense-in-depth filter correctly applied; droppedCount measures unique country codes and the log message is accurate.
server/worldmonitor/resilience/v1/cohorts/sovereign-status.json 193 UN member entries + 3 SARs (HK, MO, TW) verified; no duplicates; correct status values.
tests/resilience-universe-filter.test.mts 15 tests covering whitelist shape, helper correctness, edge cases (case-insensitivity, invalid input shapes), and exclusion of non-sovereign territories — comprehensive coverage.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    JSON["sovereign-status.json\n193 UN members + 3 SARs"]
    HELPER["rankable-universe.mjs\nisInRankableUniverse()"]

    JSON -->|readFileSync at module load| HELPER

    subgraph STATIC["seed-resilience-static.mjs"]
        FCP["finalizeCountryPayloads()"]
        FCP -->|isInRankableUniverse check| FILTER1{rankable?}
        FILTER1 -->|no| DROP1["skip + increment droppedNonRankable"]
        FILTER1 -->|yes| UPSERT["upsertDatasetRecord → merged Map"]
    end

    subgraph SCORES["seed-resilience-scores.mjs"]
        IDX["Read static index from Redis"]
        IDX --> NORM["normalize → uppercase, /^[A-Z]{2}$/"]
        NORM -->|isInRankableUniverse filter| FILTER2{rankable?}
        FILTER2 -->|no| DROP2["exclude + log droppedCount"]
        FILTER2 -->|yes| PROC["process scores for country"]
    end

    HELPER --> STATIC
    HELPER --> SCORES
Loading

Reviews (1): Last reviewed commit: "feat(resilience): universe filter — 193 ..." | Re-trigger Greptile

}

if (droppedNonRankable > 0) {
console.log(`[resilience-static] Dropped ${droppedNonRankable} non-rankable territory entries (filter: 193 UN members + 3 SARs)`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 droppedNonRankable counts dataset-field entries, not unique territories

The counter increments once per (datasetField, iso2) pair across the outer loop, so the logged number is multiplied by the number of datasets a non-rankable territory appears in. With ~12 dataset fields and ~26 filtered territories the log could read "312 non-rankable territory entries" instead of the expected ~26. This differs from the companion seeder in seed-resilience-scores.mjs, where droppedCount correctly measures unique country codes — operators comparing both logs will see mismatched numbers for the same operational event.

Consider collecting unique ISO2 codes instead:

const droppedSet = new Set();
// inside the loop:
if (!isInRankableUniverse(iso2)) {
  droppedSet.add(iso2);
  continue;
}
// after loop:
if (droppedSet.size > 0) {
  console.log(`[resilience-static] Dropped ${droppedSet.size} non-rankable territories (filter: 193 UN members + 3 SARs)`);
}

Comment thread scripts/shared/rankable-universe.mjs Outdated
Comment on lines +37 to +48
'cohorts',
'sovereign-status.json',
);

const RANKABLE_UNIVERSE = (() => {
const raw = readFileSync(SOVEREIGN_STATUS_PATH, 'utf8');
const parsed = JSON.parse(raw);
const map = new Map();
for (const entry of parsed.entries) {
if (entry?.iso2 && (entry.status === 'un-member' || entry.status === 'sar')) {
map.set(entry.iso2.toUpperCase(), entry.status);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No error handling around readFileSync IIFE

readFileSync and JSON.parse are called at module load time with no try/catch. A missing, unreadable, or malformed sovereign-status.json will throw synchronously and crash any process that imports this module — including the test runner — with an unguarded stack trace. Fail-fast is intentional here, but a structured error message would make debugging much faster when the path is wrong (e.g., during a deploy where the file hasn't been written yet):

const RANKABLE_UNIVERSE = (() => {
  let raw;
  try {
    raw = readFileSync(SOVEREIGN_STATUS_PATH, 'utf8');
  } catch (err) {
    throw new Error(`[rankable-universe] Cannot read sovereign-status.json at ${SOVEREIGN_STATUS_PATH}: ${err.message}`);
  }
  const parsed = JSON.parse(raw);
  // ... rest unchanged
})();

…erse filter (review fixup on PR #3435)

Two reviewer findings on PR #3435's initial commit:

**P1: static seeder would silently no-op post-merge.**
`shouldSkipSeedYear` (seed-resilience-static.mjs:70) skips when existing
meta has the same `sourceVersion` AND `seedYear`. Prod already has a
successful 2026 v7 static seed, so running the seeder post-merge would
log "already written, skipping" — the new whitelist filter at
`finalizeCountryPayloads` would never run, and `resilience:static:index:v1`
would remain at ~222 entries. The universe filter wouldn't take effect.

Fix: bumped `RESILIENCE_STATIC_SOURCE_VERSION` v7→v8. The next seeder
run after merge will see the new version, treat it as a fresh seed, and
apply the universe filter.

**P2: handler-side defense-in-depth was incomplete.**
`scripts/seed-resilience-scores.mjs` filtered its local `countryCodes`,
but then it calls `/api/resilience/v1/get-resilience-ranking?refresh=1`
which re-reads `listScorableCountries()` from Redis at
`server/worldmonitor/resilience/v1/_shared.ts:661`. That helper was
returning `manifest.countries` unfiltered. So even with the script's
local filter, a stale 222-country manifest could still drive the ranking
endpoint to publish 222 countries.

Fix:
- New `server/worldmonitor/resilience/v1/_rankable-universe.ts` —
  server-side TS mirror of `scripts/shared/rankable-universe.mjs`. Both
  read the SAME canonical JSON at `cohorts/sovereign-status.json`; the
  duplication is the read-path (fs.readFileSync vs ES JSON import), not
  the data.
- `_shared.ts:listScorableCountries` now applies `isInRankableUniverse`
  as a final filter. Idempotent: a fresh manifest from the post-bump
  seeder is already filtered, so the filter is a no-op then; a stale
  pre-PR-1 manifest gets filtered at handler-time.

The rankable-universe contract is now enforced at three points:
1. seed-resilience-static.mjs:finalizeCountryPayloads (write-time)
2. seed-resilience-scores.mjs (read-time defense)
3. _shared.ts:listScorableCountries (handler-time defense)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
#3433 shape gate (review fixup on PR #3435)

Cross-PR finding: PR #3433 added a cohort JSON shape gate that scans
every *.json in `server/worldmonitor/resilience/v1/cohorts/` and asserts
the cohort schema (`{ name, description, iso2: string[] }`). PR #3435
added `sovereign-status.json` to the same directory, but its shape is
a per-country PROPERTY registry (`{ entries: [{iso2, status}] }`), not
a cohort membership list. After both PRs merge, the shape gate fails
on sovereign-status.json.

Fix: directory split. Cohorts (membership lists) stay in `cohorts/`;
per-country property registries move to `registries/`. The shape gate
naturally narrows to homogeneous-shape files.

**Layout post-fix:**
- `server/worldmonitor/resilience/v1/cohorts/` — cohort membership
  lists (PR #3433's g7/nordics/gcc/sub-saharan-lic/microstate-territories)
- `server/worldmonitor/resilience/v1/registries/` — per-country
  property registries (sovereign-status; future Q4 deferred work could
  add landlocked, etc.)

**Updates:**
- Moved `cohorts/sovereign-status.json` → `registries/sovereign-status.json`
- `scripts/shared/rankable-universe.mjs` — path updated + comment
  documenting the directory-split rationale
- `server/worldmonitor/resilience/v1/_rankable-universe.ts` — path +
  comment updated
- `tests/resilience-universe-filter.test.mts` — path updated

**Verified by simulating post-merge state:** copied PR #3433's cohort
JSONs + extended parity test into this branch locally; both PRs'
tests pass (8/8 cohort + 15/15 universe-filter). Reverted the
simulation files before commit — only the PR #3435 intended changes
ship.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
#3435)

Reviewer P2 finding: get-resilience-ranking.ts returns the cached
resilience:ranking:v15 payload BEFORE calling listScorableCountries(),
and the cache key remains v15 across this PR. So after deploy + static
v8 reseed, normal API requests can still return the pre-PR cached
222-country ranking for up to the 12h ranking TTL — the new
handler-side universe filter at _shared.ts:listScorableCountries
only runs on the recompute path, not on cache hits.

Fix: add the universe filter to the cached-response read path too.
The filter is idempotent — a fresh post-PR-1 cached payload is
already universe-filtered (no-op), a stale pre-PR-1 cached payload
gets filtered at handler-time. Same recipe as the prior 3-layer
defense pattern (write-time + read-time + handler-time).

The rankable-universe contract is now enforced at FOUR layers:
1. seed-resilience-static.mjs:finalizeCountryPayloads (write-time)
2. seed-resilience-scores.mjs (read-time defense)
3. _shared.ts:listScorableCountries (recompute-path defense)
4. get-resilience-ranking.ts cache hit (cached-response defense — new)

This eliminates the operator-toil failure mode entirely. No need for
the post-merge force-refresh checklist that PR #3427 required, and no
need to bump the ranking cache key (which would be wasted churn since
PR 3 of this plan will bump it for the coverage-penalty redesign
anyway).

Test fixup: tests/resilience-ranking.test.mts:382 used 'ZZ' as a
sentinel to verify the auth gate. The new universe filter correctly
drops 'ZZ' as non-rankable, defeating the auth-gate test's intent.
Changed sentinel to 'NR' (Nauru — UN member, real country, but won't
appear in the recompute path's static-index fixture).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
…ercel esbuild compat (review fixup on PR #3435)

Reviewer P1 finding — Vercel deployment failure:

```
vc-file-system:__vc__ns__/12/server/worldmonitor/resilience/v1/_rankable-universe.js:26:65:
ERROR: Expected ";" but found "with"
```

Vercel's esbuild bundler does NOT support the ES import-attribute
syntax (`with { type: 'json' }`) used in the initial _rankable-universe.ts.
Existing server TS JSON imports in this codebase all use the plain
form WITHOUT import attributes — see e.g.
`server/worldmonitor/resilience/v1/_dimension-scorers.ts:1-2`:

```ts
import countryNames from '../../../../shared/country-names.json';
import iso2ToIso3Json from '../../../../shared/iso2-to-iso3.json';
```

Same shape applied here:

```ts
import sovereignStatus from './registries/sovereign-status.json';
```

Verified locally: typecheck clean, 609/609 resilience tests pass,
and `npx esbuild --bundle --platform=neutral --loader:.json=json`
produces a clean bundle (mimics Vercel's bundle path).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
@koala73
koala73 merged commit 4c17fc6 into main Apr 26, 2026
10 checks passed
@koala73
koala73 deleted the feat/resilience-universe-filter branch April 26, 2026 16:47
koala73 added a commit that referenced this pull request Apr 26, 2026
… (Railway bundle ENOENT) (#3441)

Production failure post-PR-#3435: Railway seed-bundle-resilience
container crashes on every cron tick with:

```
Error: ENOENT: no such file or directory,
  open '/server/worldmonitor/resilience/v1/registries/sovereign-status.json'
    at file:///app/shared/rankable-universe.mjs:49:15
[Bundle:resilience] Finished in 0.2s, ran:0 skipped:1 deferred:0 failed:1
```

**Root cause** (memory `worldmonitor-scripts-package-json-install-scope`):
Railway NIXPACKS services with `rootDirectory=scripts/` only ship files
under scripts/ into the container. PR #3435 placed sovereign-status.json
under server/worldmonitor/resilience/v1/registries/ — that resolved
locally and on Vercel (different bundlers, different file layouts) but
ENOENT'd at Railway runtime. Score-warming cron has been failing on
every tick since merge.

**Fix:** move JSON to `scripts/shared/sovereign-status.json` — the
canonical location for shared JSON consumed by both seeders AND
server-side code (alongside iso2-to-iso3.json, country-names.json
etc., per `_dimension-scorers.ts:1-2` import pattern).

**Updates:**
- `scripts/shared/rankable-universe.mjs`: sibling read,
  `resolve(here, 'sovereign-status.json')` — no path traversal
- `server/worldmonitor/resilience/v1/_rankable-universe.ts`:
  `import sovereignStatus from '../../../../scripts/shared/sovereign-status.json'`
  matching `_dimension-scorers.ts` pattern
- `tests/resilience-universe-filter.test.mts`: updated path
- `server/worldmonitor/resilience/v1/registries/`: removed (was
  intermediate location to avoid PR #3433's cohort shape gate; the
  scripts/shared/ location ALSO sits outside that gate's scope)

**Verification (4 layers):**
- `npm run typecheck` clean
- 15/15 universe-filter tests pass (full resilience suite would also
  pass; ran narrower subset since this is a path-only change)
- `npx esbuild --bundle --platform=neutral` produces clean bundle (Vercel)
- `cd scripts && node -e "import('./shared/rankable-universe.mjs')..."`
  loads 196 entries successfully (Railway bundle path simulation)

**Lesson** (now in skill `worldmonitor-scripts-package-json-install-scope`):
when a server-side TS module needs to import a JSON asset that is
ALSO consumed by a Railway NIXPACKS bundle service, the JSON MUST
live under `scripts/shared/`. server/.../whatever/ paths resolve
fine in tsc + tsx + Vercel/esbuild but ENOENT in the Railway container.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant