feat(resilience): universe filter — 193 UN members + 3 SARs (Plan 2026-04-26-002 PR 1 / U2)#3435
Conversation
…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]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryIntroduces a 196-entry universe whitelist ( Confidence Score: 4/5Safe 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
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
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)`); |
There was a problem hiding this comment.
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)`);
}| '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); | ||
| } |
There was a problem hiding this comment.
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]>
… (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]>
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
headlineEligiblegate (PR 6) can separate them from the headline ranking.What ships
server/worldmonitor/resilience/v1/cohorts/sovereign-status.json— 196-entry whitelistscripts/shared/rankable-universe.mjs—isInRankableUniverse(iso2)helper, single source of truth for both seedersscripts/seed-resilience-static.mjs— filter applied atfinalizeCountryPayloadsscripts/seed-resilience-scores.mjs— defense-in-depth filter on the static index readtests/resilience-universe-filter.test.mts— 15 tests covering whitelist shape, helper, edge casesTest plan
npx tsx --test tests/resilience-universe-filter.test.mts— 15/15 passnpm run test:data— 609/609 resilience tests passnpm run typecheckcleanseed-resilience-static.mjsto verify universe drops from ~222 to ~196 (warning log will report dropped count)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.