Skip to content

feat(resilience): financialSystemExposure dim scaffold (Phase 2 Ship 2 — flag-gated dark)#3407

Merged
koala73 merged 5 commits into
mainfrom
feat/financial-system-exposure
Apr 25, 2026
Merged

feat(resilience): financialSystemExposure dim scaffold (Phase 2 Ship 2 — flag-gated dark)#3407
koala73 merged 5 commits into
mainfrom
feat/financial-system-exposure

Conversation

@koala73

@koala73 koala73 commented Apr 25, 2026

Copy link
Copy Markdown
Owner

Summary

Plan 2026-04-25-004 Phase 2 (Ship 2) — scaffold only. Stacked on PR #3405 (Phase 1 Ship 1).

Adds the new financialSystemExposure resilience dimension behind a feature flag. The flag (RESILIENCE_FIN_SYS_EXPOSURE_ENABLED) defaults OFF so the dim ships dark — the scorer returns the empty-data shape until the 3 component seeders are populating in production. Rollout pattern matches energy v2 (plan 2026-04-24-001).

This is a deliberate two-PR split:

  • This PR (scaffold): dim id + scorer + helper + indicator registry + cache bumps + frontend + methodology doc heading + 13 contract tests
  • Follow-up PR (seeders): 3 component seeders (BIS SDMX, FATF HTML, WB IDS) + bundle registration + seeder fixture tests + cohort sanity-check + full methodology doc

Splitting at this boundary lets reviewers focus on the construct design (the formula, the U-shape, the fail-closed pattern) without the heavyweight network-integration code that will dominate the seeder PR.

What's in this PR

Components (weights total 1.0)

  • short_term_external_debt_pct_gni — 0.35 (WB IDS, lowerBetter, goalpost 15%-0% GNI)
  • bis_lbs_xborder_us_eu_uk_pct_gdp — 0.30 (BIS LBS by-parent, U-shape: <5% isolation, 5-25% sweet spot, 25-60% over-exposed, >60% Iceland-2008 territory)
  • fatf_listing_status — 0.20 (FATF discrete: black=0, gray=30, compliant=100)
  • financial_center_redundancy — 0.15 (BIS LBS by-parent count, higherBetter, 1-10)

Components 2 + 4 share the BIS LBS seed payload — no separate seeder for redundancy.

Flag-gated rollout

The RESILIENCE_FIN_SYS_EXPOSURE_ENABLED env flag defaults false. When OFF:

  • Scorer returns score=0, coverage=0, imputationClass=null — empty-data shape
  • Dim drops out of the coverage-weighted economic-domain mean
  • No preflight, no fail-closed throw

When the flag is flipped ON in production (after seeders populate):

  • Preflights all 3 required seed-meta envelopes (economic:wb-external-debt:v1, economic:bis-lbs:v1, economic:fatf-listing:v1)
  • Missing envelope → throws ResilienceConfigurationError(message, missingKeys) (two-arg form per Codex R3 P1 Add smart zoom visibility for dense map layers #2) → caught by scoreAllDimensions → routed to imputationClass='source-failure'
  • Per-country data gaps inside published envelopes → component reads return null → slot drops from blend

Cache prefix bumps (lockstep)

  • resilience:score:v13:v14:
  • resilience:ranking:v13v14
  • resilience:history:v8:v9:

The history bump is structural — adding a new dim shifts every country's overall-score baseline, and mixing pre/post points in the 30-day rolling window would manufacture false trends.

tradePolicy reweight

tradePolicy: 1.0 → 0.5 in RESILIENCE_DIMENSION_WEIGHTS so the new dim shares the economic-domain weight allocation. The half-weight on tradePolicy shifts the headline overallScore by ~0.46 points in the flag-off baseline (smaller contribution to the coverage-weighted economic-domain mean). When the flag flips on with seeders populated, the new dim's actual signal will rebalance the headline.

Files (25 changed, +791 / −70)

  • Server: _dimension-scorers.ts (dim id, weights, domains, order, types, scorers, scorer fn, helper, payload accessors), _indicator-registry.ts (4 new entries), _shared.ts (3 cache prefix bumps), _source-failure.ts (no edit needed — propagates via existing path)
  • Mirrors: scripts/{seed-resilience-scores,backtest,benchmark,validate-correlation,validate-backtest,compare-current-vs-proposed}.mjs, api/health.js — every literal v13/v8 site migrated to v14/v9
  • Health registries: api/health.js SEED_META + KEY_TO_DOMAIN, api/seed-health.js SEED_DOMAINS — 3 new keys in BOTH (per memory feedback_two_health_endpoints_must_match)
  • Frontend: src/components/resilience-widget-utils.ts label map ("Fin. Exposure"), src/components/ResilienceWidget.ts "19 dimensions" → "20 dimensions"
  • Methodology: docs/methodology/country-resilience-index.mdx — new #### Financial System Exposure H4 + indicator table + license attribution + cross-link to construct rationale
  • Tests: 13 new in tests/resilience-financial-system-exposure.test.mts (fail-closed preflight + flag-off rollout + formula math + U-shape sanity + FATF discrete mapping + component-read contract); existing tests updated for v14/v9 prefixes, 22-dim count, flag-gated-dark exemption in release-gate, OFAC-not-read invariant for the new dim

What this PR does NOT ship (deferred to follow-up PR)

  • scripts/seed-bis-lbs.mjs (BIS SDMX WS_LBS_D_PUB by-parent integration — enumerated ISO2 parents per Codex R4 P1 Add smart zoom visibility for dense map layers #2)
  • scripts/seed-fatf-listing.mjs (FATF HTML scrape with dynamic publication-URL follow + monthly cadence + 90d cache TTL fallback)
  • scripts/seed-wb-external-debt.mjs (WB IDS short-term external debt as % GNI)
  • Bundle registration in scripts/seed-bundle-macro.mjs
  • tests/seed-{bis-lbs,fatf-listing,wb-external-debt}.test.mjs — fixture-based parser tests with Brazil 2024Q4 ground-truth anchor for BIS LBS and 2026-02-13 publication for FATF
  • Integration tests: no double-counting between tradePolicy and financialSystemExposure; no double-counting between financialSystemExposure and liquidReserveAdequacy
  • Cohort sanity-check: Russia / Iran / DPRK score < 20 on financialSystemExposure (gates the construct calibration)
  • Full methodology doc docs/methodology/financial-system-exposure.md with rejected alternatives + license attribution
  • Bounded-movement gate (60% of countries |Δ| < 3 points)

These need real-API integration with BIS SDMX, FATF web parsing, and WB IDS — best landed as a separate PR with fixture-recorded tests so reviewers can scrutinize the network code separately from the construct design.

Test plan

  • npm run typecheck — exit 0
  • npm run typecheck:api — exit 0
  • npm run test:data7115/7115 tests pass
    • tests/resilience-financial-system-exposure.test.mts — 13 new tests pinning the 4-component formula, flag-off baseline, fail-closed preflight, U-shape anchors, FATF discrete mapping, component-read contract
    • tests/resilience-cache-keys-health-sync.test.mts — parity test extended for v14 score+ranking, v9 history; asserts old prefixes absent in non-comment code
    • tests/resilience-methodology-lint.test.mts — heading "Financial System Exposure" → dim id financialSystemExposure
    • tests/resilience-release-gate.test.mts — 22-dim count + flag-gated-dark allow-list (must remove when flag flips on)
    • tests/resilience-indicator-registry.test.mts — 22-dim coverage + non-experimental weights total 1.0 per dim
  • npm run lint (biome) — exit 0
  • npm run lint:md — exit 0
  • npm run version:check — exit 0
  • Repo-wide grep audit: zero non-comment resilience:(score|ranking):v13 / resilience:history:v8 literals

Post-Deploy Monitoring & Validation

  • What to monitor/search
    • Logs: [resilience] invocations after deploy — expect compute-on-miss for v14 score keys until seed-resilience-scores cron warms them
    • Metrics: /api/health resilienceRanking registered key resilience:ranking:v14
    • With flag OFF (default): no RESILIENCE_FIN_SYS_EXPOSURE_ENABLED errors should appear; Sentry should be silent on the new dim
  • Validation checks
    • redis-cli GET 'resilience:ranking:v14' returns a populated record after seed-bundle-resilience cron runs
    • redis-cli GET 'resilience:score:v14:US' shape includes financialSystemExposure dim entry with score=0, coverage=0, imputationClass=null (flag-off baseline)
  • Expected healthy behavior
    • First 6h: edge handler computes-on-miss for v14 score keys; per-country GET avg latency rises modestly until warm
    • After cron: ranking + 222 country score keys populated under v14 prefix; latency returns to baseline
    • With flag OFF: every country's financialSystemExposure.coverage = 0; the dim is "structurally present, signal-dark"
  • Failure signal / rollback trigger
    • /api/health reports resilience:ranking:v14 STALE > 12h after merge → Railway cron failing; check seed-bundle-resilience logs and re-deploy seeder
    • Spike in 5xx on /api/resilience/ranking immediately post-deploy → revert the PR; v13 reads still work since we didn't delete the seeder key
    • With flag accidentally flipped ON (without seeders shipped) → ResilienceConfigurationError on every scoreFinancialSystemExposure call → dim shows imputationClass='source-failure' for all countries → expected behavior, but indicates the flag flip was premature; flip OFF to revert
  • Validation window & owner
    • Window: first 12h after merge
    • Owner: Elie

Activation runbook (for after the seeder PR ships)

  1. Merge the seeder follow-up PR
  2. Run seed-bis-lbs, seed-fatf-listing, seed-wb-external-debt manually via railway run to populate Redis
  3. Verify all 3 seed-meta envelopes are present:
    redis-cli GET 'seed-meta:economic:bis-lbs'
    redis-cli GET 'seed-meta:economic:fatf-listing'
    redis-cli GET 'seed-meta:economic:wb-external-debt'
    
  4. Set RESILIENCE_FIN_SYS_EXPOSURE_ENABLED=true in Vercel + Railway env config
  5. Flush v14 caches: bulk DEL resilience:score:v14:* + DEL resilience:ranking:v14
  6. Run seed-resilience-scores.mjs to bulk-warm v14 score keys with the new dim's actual signal
  7. Cohort audit: snapshot resilience:score:v14:* for all 222 countries
  8. Sanity-check anchors: Russia / Iran / DPRK score < 20 on financialSystemExposure. If they don't, the construct is calibrated wrong — revert the flag flip and retune
  9. Bounded-movement gate: at least 60% of countries should have |Δ| < 3 points overall; outliers > 12 must be in the explicitly-predicted set (RU/IR/KP/CU/VE/BY/LY/MM)
  10. Remove the FLAG_GATED_DARK_DIMENSIONS allow-list entry in tests/resilience-release-gate.test.mts in the same commit that flips the flag

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via Claude Code + Compound Engineering v3.0.0

Compound Engineering v3.0.0

@vercel

vercel Bot commented Apr 25, 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 25, 2026 6:46pm

Request Review

@greptile-apps

greptile-apps Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR scaffolds the financialSystemExposure resilience dimension (Phase 2 of plan 2026-04-25-004) behind a feature flag, adding a 4-component weighted blend (WB IDS short-term debt, BIS LBS U-shape exposure, FATF listing status, financial-center redundancy) along with cache prefix bumps (score v14, history v9, ranking v14), indicator registry entries, frontend label/count updates, and 13 contract tests. The dim ships dark by default and will only activate once the three component seeders land in a follow-up PR.

Two P1 issues need resolution before the flag is flipped ON:

  • Seed-meta key mismatch: the preflight reads seed-meta:economic:wb-external-debt:v1 (appending the data-key version to seed-meta:), but api/health.js and api/seed-health.js monitor seed-meta:economic:wb-external-debt (no :v1). If the forthcoming seeders follow the health.js convention, the preflight will always fail → every country gets source-failure despite correct seeder operation.
  • 30-point scoring cliff at 25% BIS LBS exposure: normalizeBandLowerBetter is discontinuous at value=25 — a country at 25.00% scores 100 on the component while one at 25.01% scores ~70, a 9-point swing in the overall dimension score.

Confidence Score: 3/5

Safe to merge as a dark scaffold (flag OFF), but two P1 issues must be fixed before flag activation.

Two P1 findings present: seed-meta key mismatch would cause total source-failure on flag flip, and the U-shape cliff would corrupt rankings near the 25% boundary. Neither affects the current flag-off baseline, but both must be resolved before the seeder PR activates the dim. Score is 3/5 (below the 4/5 P1 ceiling) because the two P1s compound — one causes all countries to show source-failure and the other corrupts relative rankings.

server/worldmonitor/resilience/v1/_dimension-scorers.ts for both P1 issues; tests/resilience-financial-system-exposure.test.mts should be updated alongside to fix the mirrored seed-meta key bug in test helpers.

Important Files Changed

Filename Overview
server/worldmonitor/resilience/v1/_dimension-scorers.ts Adds scoreFinancialSystemExposure scorer with 4-component weighted blend, U-shape band normalization, and flag-gated fail-closed preflight; two P1 bugs: seed-meta key format mismatch between preflight and health convention, and a 30-point scoring cliff at the 25% BIS LBS boundary
server/worldmonitor/resilience/v1/_indicator-registry.ts Adds 4 indicator specs for financialSystemExposure; bisLbsXborderPctGdp goalposts (best=15) don't reflect the U-shape's actual peak at 25%
server/worldmonitor/resilience/v1/_shared.ts Cache prefix bumps score v13→v14, history v8→v9, ranking v13→v14 — well-documented and consistent with prior patterns
api/health.js Adds three new seed-meta monitoring entries with unversioned keys — diverges from the :v1-suffixed keys used in the scorer's preflight
api/seed-health.js Adds matching SEED_DOMAINS entries; intervalMin values correctly reflect maxStaleMin/2
tests/resilience-financial-system-exposure.test.mts 13 new contract tests; test helpers use the :v1-suffixed seed-meta keys that mirror the preflight bug rather than catching the mismatch
tests/resilience-release-gate.test.mts Updated to 22-dim count with FLAG_GATED_DARK_DIMENSIONS allow-list and clear removal instructions
src/components/ResilienceWidget.ts Updates dimension count from 19 to 20 in the tooltip string
src/components/resilience-widget-utils.ts Adds financialSystemExposure: 'Fin. Exposure' label entry

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[scoreFinancialSystemExposure called] --> B{RESILIENCE_FIN_SYS_EXPOSURE_ENABLED?}
    B -- OFF default --> C[Return empty-data shape\nscore=0, coverage=0, imputationClass=null]
    B -- ON --> D[Preflight 3 seed-meta keys\nwb-external-debt, bis-lbs, fatf-listing]
    D --> E{All 3 seed-meta present?}
    E -- No --> F[Throw ResilienceConfigurationError\nmissingKeys array]
    F --> G[Caught by scoreAllDimensions\nimputationClass=source-failure]
    E -- Yes --> H[Read 3 data payloads in parallel\nWB IDS + BIS LBS + FATF]
    H --> I[readWbExternalDebtPct\nnormalizeLowerBetter 0-15%]
    H --> J[readBisLbsCountry\nnormalizeBandLowerBetter U-shape]
    H --> K[readFatfStatus\nblack=0 gray=30 compliant=100]
    H --> L[readBisLbsCountry parentCount\nnormalizeHigherBetter 1-10]
    I --> M[weightedBlend\n0.35+0.30+0.20+0.15=1.0]
    J --> M
    K --> M
    L --> M
    M --> N[ResilienceDimensionScore\nscore+coverage+imputationClass]
Loading

Reviews (1): Last reviewed commit: "feat(resilience): financialSystemExposur..." | Re-trigger Greptile

Comment on lines +1200 to +1217

// Preflight: verify the 3 required seed envelopes are published.
const requiredSeedKeys = [
RESILIENCE_WB_EXTERNAL_DEBT_KEY,
RESILIENCE_BIS_LBS_KEY,
RESILIENCE_FATF_LISTING_KEY,
] as const;
const missing: string[] = [];
for (const key of requiredSeedKeys) {
const meta = await reader(`seed-meta:${key}`);
if (!meta) missing.push(key);
}
if (missing.length > 0) {
throw new ResilienceConfigurationError(
`RESILIENCE_FIN_SYS_EXPOSURE_ENABLED=true but required seed-meta absent for: ${missing.join(', ')}. ` +
'Provision the macro bundle component seeders (seed-bis-lbs, seed-fatf-listing, ' +
'seed-wb-external-debt) and confirm Redis populates BEFORE flipping the flag. ' +
'Or set RESILIENCE_FIN_SYS_EXPOSURE_ENABLED=false to keep the dim dark. ' +

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.

P1 Seed-meta preflight key includes :v1 suffix — mismatches health monitoring

The preflight loop does reader(\seed-meta:${key}`)wherekey=RESILIENCE_WB_EXTERNAL_DEBT_KEY='economic:wb-external-debt:v1'. This reads seed-meta:economic:wb-external-debt:v1. But api/health.jsmonitorsseed-meta:economic:wb-external-debt(no:v1) and api/seed-health.jsuses the same unversioned form. The mismatch applies equally to thebis-lbsandfatf-listing` keys.

When the seeder PR ships, if the seeders follow the existing health.js convention and write to the unversioned seed-meta:economic:wb-external-debt key, the preflight here will find nothing → ResilienceConfigurationError thrown on every scoreFinancialSystemExposure call → every country shows imputationClass='source-failure' despite working seeders. The test helper uses the :v1 form, so it mirrors the bug rather than catching it.

Consider using explicit, unversioned seed-meta keys in the requiredSeedKeys array to match the convention established in api/health.js and api/seed-health.js.

Comment on lines +563 to 581
function normalizeBandLowerBetter(value: number): number {
if (!Number.isFinite(value) || value < 0) return 50;
if (value < 5) {
// Low integration: 0% → 60, 5% → 70.
return roundScore(60 + (value / 5) * 10);
}
if (value <= 25) {
// Sweet spot: 5% → 75, 25% → 100.
return roundScore(75 + ((value - 5) / 20) * 25);
}
if (value <= 60) {
// Over-exposed: 25% → 70, 60% → 30.
return roundScore(70 - ((value - 25) / 35) * 40);
}
// Iceland-2008 territory: 60% → 30, every additional 1% drops 0.5pt; clamped 0.
return roundScore(Math.max(0, 30 - (value - 60) * 0.5));
}

// `normalizeSanctionCount` retired in plan 2026-04-25-004 Phase 1. The

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.

P1 30-point scoring cliff at the 25% boundary in normalizeBandLowerBetter

The two adjacent segments are not continuous at value = 25:

  • Sweet spot (branch value <= 25): at 25.00% → score = 75 + (20/20)*25 = 100
  • Over-exposed (branch value <= 60): at 25.01% → score ≈ 70

A country with BIS LBS exposure of 24.9% scores ≈ 99.9 on this component; at 25.1% it scores ≈ 70 — a 30-point cliff on a component with weight 0.30, translating to a ~9-point swing in the dimension score. This instability is flag-gated off today but will surface as a ranking cliff once the flag is flipped.

There is also a 5-point jump at value = 5: the low-integration branch ends at ~70 (approaching from below) while the sweet-spot branch starts at 75 (at exactly 5%). The standard fix is to align boundary values so adjacent segments share the same endpoint.

Comment on lines +1303 to +1315
type FatfStatus = 'black' | 'gray' | 'compliant';

function readFatfStatus(raw: unknown, countryCode: string): FatfStatus | null {
if (raw == null || typeof raw !== 'object') return null;
const listings = (raw as { listings?: Record<string, unknown> }).listings;
if (!listings || typeof listings !== 'object') return null;
const status = listings[countryCode];
if (status === 'black' || status === 'gray' || status === 'compliant') return status;
// Unknown country = compliant (FATF only enumerates non-compliant
// jurisdictions; absence from both lists means compliant).
return 'compliant';
}

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 readFatfStatus silently maps empty listings dict to compliant for all countries

When the FATF seeder's HTML parser succeeds in writing the seed envelope (satisfying the preflight) but fails to populate any country entries, listings will be a valid but empty object {}. Every lookup then falls through to return 'compliant', scoring every country 100 on FATF with no error raised and coverage non-zero. A guard checking that listings has at least one entry before returning the default 'compliant' would catch this seeder regression.

Comment on lines +259 to +275
// that gate by definition. Component carries weight 0.35 inside the
// dim regardless of tier — the tier is a documentation classification.
tier: 'enrichment',
coverage: 125,
license: 'open-data',
},
{
id: 'bisLbsXborderPctGdp',
dimension: 'financialSystemExposure',
description: 'BIS LBS sum of by-parent cross-border claims (US/UK/major-EU/CH/JP/CA/AU/SG) as % of GDP; U-shape band — both isolation (<5%) and over-exposure (>60%) score low',
direction: 'lowerBetter', // U-shape is "lowerBetter" in semantic sense (concentrated exposure penalized)
goalposts: { worst: 60, best: 15 },
weight: 0.30,
sourceKey: 'economic:bis-lbs:v1',
scope: 'global',
cadence: 'quarterly',
tier: 'enrichment',

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 bisLbsXborderPctGdp goalposts don't match the U-shape scoring function

The registry entry specifies goalposts: { worst: 60, best: 15 }, implying a linear lowerBetter scale peaking at 15%. But normalizeBandLowerBetter peaks at 25% (score=100); a value of 15% gets 75 + ((15-5)/20)*25 = 87.5, not 100. These mismatched goalposts will mislead any tooling or documentation that reads the registry for normalization parameters. Consider { worst: 0, best: 25 } or a comment clarifying the goalposts are nominal documentation only.

Comment thread api/health.js
Comment on lines 118 to 127
imfGrowth: 'economic:imf:growth:v1',
imfLabor: 'economic:imf:labor:v1',
imfExternal: 'economic:imf:external:v1',
// plan 2026-04-25-004 Phase 2: financialSystemExposure data keys.
wbExternalDebt: 'economic:wb-external-debt:v1',
bisLbs: 'economic:bis-lbs:v1',
fatfListing: 'economic:fatf-listing:v1',
climateZoneNormals: 'climate:zone-normals:v1',
shippingRates: 'supply_chain:shipping:v2',
chokepoints: 'supply_chain:chokepoints:v4',

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 New data keys missing from api/bootstrap.js

AGENTS.md states: "New data sources MUST have bootstrap hydration wired in api/bootstrap.js." The three new data keys are registered in STANDALONE_KEYS and SEED_META here but are absent from api/bootstrap.js. Since the seeders aren't shipping in this PR the omission is presumably intentional — but it should be tracked as a hard requirement for the seeder follow-up PR.

Base automatically changed from feat/trade-policy-rename to main April 25, 2026 16:15
koala73 added a commit that referenced this pull request Apr 25, 2026
P1 — 30-point scoring cliff at 25% boundary in normalizeBandLowerBetter:
  Original draft had piecewise-linear segments with mismatched endpoints:
    - At value=25:    sweet spot ended at 100, over-exposed started at 70
                      → 30-point cliff × 0.30 weight ≈ 9-pt headline swing
    - At value=5:     low-int ended at 70, sweet started at 75 → 5-pt jump
  Cliffs in piecewise-linear scorers cause ranking instability for
  countries near band edges (24.9% scores ~99.9, 25.1% scores ~70).
  Re-anchored adjacent segments to share endpoints — function is now
  piecewise-CONTINUOUS at 5%, 25%, and 60% transitions:
    0%-5%:    60 → 75   (slope +3/pct, was +2)
    5%-25%:   75 → 100  (unchanged)
    25%-60%:  100 → 30  (slope −2/pct, was 70 → 30 / slope −1.14)
    60%+:     30 → 0    (unchanged)
  New regression test pins continuity at all three boundaries (samples
  values immediately above/below each transition, asserts |Δ| ≤ 1pt).

P2 — readFatfStatus defaults empty listings dict to "compliant":
  An empty `listings: {}` payload that bypassed the seeder's validate()
  would silently score every country at 100 (compliant default), masking
  a parser regression. Added defense-in-depth guard: empty dict → null
  component score → slot drops out of weighted blend → coverage shrinks
  visibly rather than the dim looking healthy. Seeder validate already
  enforces ≥1 black + ≥12 grey so this can't reach production through
  the normal write path; the guard costs nothing and catches malformed
  payloads that bypass validation. New regression test pins.

P2 — bisLbsXborderPctGdp goalposts mismatch U-shape peak:
  Registry entry had `goalposts: { worst: 60, best: 15 }` implying a
  linear lowerBetter scale peaking at 15%. The U-shape function actually
  peaks at 25% (value=15% scores 87.5, not 100). Updated to
  `{ worst: 60, best: 25 }` (over-exposed branch, peak anchor) with an
  explicit comment that goalposts here are documentation-only — the
  actual scorer uses normalizeBandLowerBetter, not a generic linear
  normalizer. Tooling reading the registry should consult the scorer
  helper directly.

P2 (resolved differently than originally suggested) —
api/bootstrap.js missing 3 new data keys:
  Greptile flagged that AGENTS.md requires bootstrap hydration for new
  data sources. Verified the bootstrap-hydration-coverage test enforces
  this via a `getHydratedData` consumer requirement in src/.
  The 3 new keys (economic:wb-external-debt:v1, economic:bis-lbs:v1,
  economic:fatf-listing:v1) are SERVER-ONLY — they feed
  scoreFinancialSystemExposure inside /api/resilience/* handlers; no
  client panel consumes them directly. Adding them to bootstrap without
  a consumer would fail the parity tests. Documented in api/bootstrap.js
  as a deferred entry: "if a future PR adds a client panel that displays
  raw BIS LBS / FATF / WB external-debt data, register the keys here
  AND add the corresponding consumer + cache-keys.ts entries in the
  same PR."

Note on Greptile's P1 #1 (preflight `:v1` suffix mismatch): already
resolved in commit d904103 (uses `resolveSeedMetaKey` from
_dimension-freshness.ts). Greptile reviewed an earlier commit.

Quality gates:
  - npm run typecheck:api: clean
  - npm run lint + lint:md + version:check: clean
  - npm run test:data: 7156/7156 pass (was 7154; +2 regression guards)

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v3.0.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <[email protected]>
koala73 and others added 5 commits April 25, 2026 22:39
…2 — flag-gated dark)

Adds the new `financialSystemExposure` resilience dimension introduced in
plan 2026-04-25-004 Phase 2, behind the
`RESILIENCE_FIN_SYS_EXPOSURE_ENABLED` env flag. The flag defaults OFF so
the dim ships dark — the scorer returns the empty-data shape (score=0,
coverage=0, imputationClass=null) until the 3 component seeders
(seed-bis-lbs, seed-fatf-listing, seed-wb-external-debt) are populating
in production. Rollout pattern matches energy v2 (plan
2026-04-24-001).

Components (weights total 1.0 inside the dim):
  short_term_external_debt_pct_gni    0.35  (WB IDS, lowerBetter, 15-0)
  bis_lbs_xborder_us_eu_uk_pct_gdp    0.30  (BIS LBS by-parent, U-shape)
  fatf_listing_status                  0.20  (FATF, discrete: black=0, gray=30, compliant=100)
  financial_center_redundancy          0.15  (BIS LBS by-parent count, higherBetter, 1-10)

When the flag flips ON, the scorer preflights all 3 required seed-meta
envelopes (the BIS LBS seed serves both Component 2 and Component 4 —
no separate Component 4 seeder). Missing envelopes throw
`ResilienceConfigurationError(message, missingKeys)` (two-arg form per
Codex R3 P1 #2) which `scoreAllDimensions` catches and routes to
`imputationClass='source-failure'`. Per-country data gaps inside an
otherwise-published envelope are distinct: per-component reads return
null, the slot drops out of the weighted blend.

Cache prefixes bumped in lockstep with the new dim:
  resilience:score:v13:    → v14:
  resilience:ranking:v13   → v14
  resilience:history:v8:   → v9:

The scaffold also reweights `tradePolicy` 1.0 → 0.5 in
RESILIENCE_DIMENSION_WEIGHTS (the new dim shares the economic-domain
weight). This shifts the headline overallScore by ~0.46 points in the
flag-off baseline because the half-weighted tradePolicy contributes
proportionally less to the coverage-weighted economic-domain mean.
When the flag flips on with seeders populated, the new dim's actual
signal will rebalance the headline score.

What this PR ships:
  - new `scoreFinancialSystemExposure` + `normalizeBandLowerBetter` U-shape helper
  - 4 new INDICATOR_REGISTRY entries (BIS-derived tagged non-commercial/enrichment per Codex R1 #8)
  - api/health.js + api/seed-health.js dual-registry entries for the 3 new seed keys
  - frontend label map + ResilienceWidget "20 dimensions" copy
  - methodology doc heading "Financial System Exposure" + indicator table
  - tests/resilience-financial-system-exposure.test.mts: 13 tests pinning the
    formula contract + fail-closed preflight + flag-off rollout posture
  - parity test extension for v14/v9 prefixes
  - 22-dim count update across release-gate + handlers + indicator-registry tests
  - flag-gated-dark exemption in release-gate's coverage check

What this PR does NOT ship (deliberately deferred):
  - The 3 component seeders themselves (seed-bis-lbs, seed-fatf-listing,
    seed-wb-external-debt). These need real-API integration with BIS SDMX,
    FATF HTML scraping, and WB IDS — best landed as a separate PR with
    fixture-recorded tests.
  - Methodology doc `docs/methodology/financial-system-exposure.md` with
    full alternatives + license attribution.
  - Bundle registration in scripts/seed-bundle-macro.mjs.
  - Cohort sanity-check anchor test (RU/IR/KP < 20 on
    financialSystemExposure) — needs real seeder data.

Once those land and seeders populate Redis, flip
`RESILIENCE_FIN_SYS_EXPOSURE_ENABLED=true` in Vercel + Railway env
config to activate the dim.

Stacked on PR #3405 (Phase 1 Ship 1).

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v3.0.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <[email protected]>
…logy doc

Completes Phase 2 of plan 2026-04-25-004 by shipping the 3 component
seeders, registering them in the macro bundle, adding 34 new fixture-
based tests, and writing the full methodology doc with license attribution.

Seeders (per plan §Files (Phase 2)):
  scripts/seed-wb-external-debt.mjs    — WB IDS short-term external debt as % GNI
                                         (DT.DOD.DSTC.IR.ZS × DT.DOD.DECT.GN.ZS,
                                          mrv=5 + pickLatestPerCountry per memory
                                          `feedback_wb_bulk_mrv1_null_coverage_trap`)
  scripts/seed-bis-lbs.mjs              — BIS LBS by-parent SDMX integration
                                         (12-dim key with L_POS_TYPE=N per Codex
                                          R3 P1 #1; 16 enumerated parent ISO2 codes
                                          per Codex R4 P1 #2 — no `4F` aggregate;
                                          ISO2 direct, no M49 mapping per CL_BIS_IF_REF_AREA)
  scripts/seed-fatf-listing.mjs         — FATF entry-page parser with dynamic
                                         publication-URL follow + sanity-check
                                         band gates + monthly cadence + 90d cache
                                         TTL fallback

Bundle registration:
  scripts/seed-bundle-macro.mjs — Option A per Codex R1 #5 (less operational
                                  overhead than provisioning a new bundle).

Tests (34 new):
  tests/seed-wb-external-debt.test.mjs  — combineExternalDebt formula pinning,
                                          IMF Article IV 15% GNI anchor,
                                          conservative-year selection, validate floor
  tests/seed-bis-lbs.test.mjs           — combineLbsByCounterparty Brazil 2024Q4
                                          ground-truth anchor, 1% GDP threshold
                                          for parentCount, BIS aggregate-code
                                          allow-list (5J/5A skipped), SDMX-JSON
                                          shape parsing, parser-regression guard
  tests/seed-fatf-listing.test.mjs      — findPublicationLink entry-page anchor
                                          extraction (case-insensitive), country-
                                          name lookup with apostrophe handling
                                          (`People's` → `peoples`), pub-date
                                          inference from URL slug + header,
                                          DPRK-on-call-for-action invariant

Methodology doc:
  docs/methodology/financial-system-exposure.md — full construct definition,
                                                   per-component formulas + score
                                                   shapes + coverage matrices,
                                                   fail-closed preflight,
                                                   methodology invariants,
                                                   sanctions-isolated-jurisdiction
                                                   sanity-check anchors (RU/IR/KP/...
                                                   < 20), bounded-movement gate
                                                   (60%+ |Δ|<3pt), data-source
                                                   licensing (BIS terms of use),
                                                   alternatives considered (5),
                                                   future considerations (Phase 3-5)

Quality:
  - npm run typecheck + typecheck:api: clean
  - npm run test:data: 7149/7149 pass (was 7115; +34 new seeder tests)
  - npm run lint + lint:md + version:check: clean
  - Edge function bundle check: clean

Activation runbook (when ready to flip the dim live):
  1. Merge this PR
  2. Run seed-wb-external-debt + seed-bis-lbs + seed-fatf-listing manually
     via `railway run --service seed-bundle-macro -- node scripts/<seeder>.mjs`
  3. Verify all 3 seed-meta envelopes published:
       redis-cli GET 'seed-meta:economic:wb-external-debt'
       redis-cli GET 'seed-meta:economic:bis-lbs'
       redis-cli GET 'seed-meta:economic:fatf-listing'
  4. Set RESILIENCE_FIN_SYS_EXPOSURE_ENABLED=true in Vercel + Railway env config
  5. Flush v14 caches: bulk DEL resilience:score:v14:* + DEL resilience:ranking:v14
  6. Run seed-resilience-scores.mjs to bulk-warm v14 with the new dim's signal
  7. Cohort audit: snapshot resilience:score:v14:* for all 222 countries;
     RU/IR/KP must score < 20 on financialSystemExposure (gate the construct
     before stable-rollout)
  8. Bounded-movement gate: 60% of countries |Δ|<3pt; outliers > 12pt must
     be in the explicitly-predicted RU/IR/KP/CU/VE/BY/LY/MM set
  9. Remove FLAG_GATED_DARK_DIMENSIONS allow-list entry in
     tests/resilience-release-gate.test.mts in the same commit that flips
     the flag

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v3.0.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <[email protected]>
P1 — BIS LBS sequential-fetch timeout math:
  Sequential 16 parents × 60s timeout = 960s worst-case, exceeding the
  bundle's 600s timeoutMs. SIGTERM mid-flight would have leaked the
  child-lock + produced a covertly-degraded payload (per memory
  `bundle-runner-sigkill-leaks-child-lock`). Fix: parallelize parent
  fetches with concurrency=4 via a bounded-concurrency runner. Caps wall
  time at ~4 × 60s = 240s on the slow path while staying polite to BIS.

P2 — BIS LBS parent-success gate:
  Previous "all 16 failed" short-circuit was too permissive. If 15 of 16
  parent fetches failed, a single-parent payload would pass validate
  (>100 counterparty floor) and skew Component 4 (financialCenterRedundancy)
  low for every counterparty until the next successful run. Added
  MIN_SUCCESSFUL_PARENTS=12 gate; below that, throw → seed-meta unchanged
  → previous valid payload stays alive under cache TTL.

P2 — FATF findPublicationLink prefers highest-year anchor:
  Previous "first anchor matching label" was vulnerable to FATF page
  layouts where a sidebar links to historical publications using the
  same wording. Fix: collect all candidates, sort by year (URL slug or
  anchor text), return highest. Logs all candidates at WARN when more
  than one matches so ops can spot drift.

P2 — FATF unmatched country-name surfacing:
  Previous parser silently dropped country names not in
  shared/country-names.json. If FATF introduced a new spelling
  ("Mauretania", "Türkiye"), the country fell out of the listing and
  defaulted to "compliant" (score 100) — materially shifting its
  financialSystemExposure score under a fresh seed-meta. Fix:
  extractListedCountries now returns { listed, unmatchedCandidates }.
  Seeder logs unmatched at WARN; throws if > 2 candidates unmatched
  (indicates parser drift / new spellings the lookup needs to learn).

P2 — WB external-debt yearMismatch metadata:
  Composition can mix vintages of the two source indicators (different
  WB IDS lag patterns). Previous output silently used min(year) as the
  conservative anchor. Fix: emit `yearMismatch: boolean` +
  `shortTermPctOfTotalDebtYear` + `totalDebtPctOfGniYear` on each
  country record so the dashboard / scorer / audit can flag countries
  with cross-year composition. Pinning test asserts min(year) selection
  + correct flag.

P3 — BIS LBS upper-bound corruption guard:
  Added `latestVal > 1e8` skip in extractClaimsByCounterparty. 1e8
  millions = $100T (>half of global GDP), well above any plausible
  bilateral claim. Drops corrupt SDMX values silently rather than
  letting them inflate totalXborderPctGdp downstream.

P3 — BIS LBS validation floor 100 → 150:
  BIS LBS counterparty coverage is ~200+ jurisdictions. Floor of 100
  would have accepted a payload with massive coverage regression.
  Tightened to 150.

P3 — FATF grey-list floor 8 → 12 + band 8-40 → 12-40:
  Historical FATF grey-list size has been 15+ since 2020. Floor of 8
  was too lenient. Tightened to 12 with comment explaining the
  historical band.

P3 — FATF parse-failure WARN logging:
  All sanity-check throw paths in seed-fatf-listing now emit a
  console.warn explaining the failure and noting "previous valid
  payload remains under cache TTL" before throwing. Plan called for
  "warn loudly"; the implicit fall-back-to-cache pattern is now
  diagnostic-friendly.

Suggestion — BIS LBS droppedForMissingGdp provenance:
  Counterparties seen in BIS LBS but dropped because no WB GDP record
  was available are now collected into a `droppedForMissingGdp` array
  on the seed payload. Surfaces silent coverage gaps for ops triage
  without polluting the main `countries` map.

Suggestion — methodology doc operational footguns + smoke test:
  New §"Common operational footguns" section in
  docs/methodology/financial-system-exposure.md surfacing the BIS LBS
  4F-aggregate-rejection lesson + ISO 3166-1 (not M49) clarification +
  pre-flag-flip smoke test commands.

Quality gates:
  - npm run typecheck + typecheck:api: clean
  - npm run lint + lint:md + version:check: clean
  - npm run test:data: 7153/7153 pass (was 7149; +4 hardening tests)

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v3.0.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <[email protected]>
…seed-meta keys

P1 reviewer catch: the preflight in scoreFinancialSystemExposure was
reading `seed-meta:economic:<key>:v1` while runSeed
(scripts/_seed-utils.mjs) writes the freshness record at
`seed-meta:${dataKey.replace(/:v\d+$/, '')}` — i.e. with the trailing
:v\d+ stripped. Once `RESILIENCE_FIN_SYS_EXPOSURE_ENABLED=true` was
flipped, every /api/resilience/* request would have hit the missing-
seed-meta path indefinitely, throwing ResilienceConfigurationError and
stamping every country's financialSystemExposure as
imputationClass='source-failure' even with healthy seeders running.

The same unversioned shape is already used by api/health.js +
api/seed-health.js + every other in-tree scorer that walks
seed-meta keys via _dimension-freshness.ts.

Fix: route the preflight through `resolveSeedMetaKey` from
_dimension-freshness.ts. That helper already strips the trailing
:v\d+ AND applies the SOURCE_KEY_META_OVERRIDES table — the canonical
in-tree pattern for "given a registry data-key, return the seed-meta
key that runSeed actually writes." Inlining the regex would have
re-introduced the same writer/reader drift this helper exists to
prevent.

Tests:
  - All formula + preflight tests (which previously mocked the
    incorrect versioned form) updated to the unversioned key shape so
    they actually exercise the production read path.
  - New regression-guard test "preflight reads UNVERSIONED seed-meta
    keys (matches runSeed write-key shape)" pins the exact key shape
    the scorer probes. Asserts both presence of the unversioned form
    AND absence of the versioned form. A future refactor that
    accidentally re-versions the preflight will fail loudly here
    instead of silently routing every country to source-failure.

Quality gates:
  - npm run typecheck:api: clean
  - npm run test:data: 7154/7154 pass (was 7153; +1 contract guard)

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v3.0.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <[email protected]>
P1 — 30-point scoring cliff at 25% boundary in normalizeBandLowerBetter:
  Original draft had piecewise-linear segments with mismatched endpoints:
    - At value=25:    sweet spot ended at 100, over-exposed started at 70
                      → 30-point cliff × 0.30 weight ≈ 9-pt headline swing
    - At value=5:     low-int ended at 70, sweet started at 75 → 5-pt jump
  Cliffs in piecewise-linear scorers cause ranking instability for
  countries near band edges (24.9% scores ~99.9, 25.1% scores ~70).
  Re-anchored adjacent segments to share endpoints — function is now
  piecewise-CONTINUOUS at 5%, 25%, and 60% transitions:
    0%-5%:    60 → 75   (slope +3/pct, was +2)
    5%-25%:   75 → 100  (unchanged)
    25%-60%:  100 → 30  (slope −2/pct, was 70 → 30 / slope −1.14)
    60%+:     30 → 0    (unchanged)
  New regression test pins continuity at all three boundaries (samples
  values immediately above/below each transition, asserts |Δ| ≤ 1pt).

P2 — readFatfStatus defaults empty listings dict to "compliant":
  An empty `listings: {}` payload that bypassed the seeder's validate()
  would silently score every country at 100 (compliant default), masking
  a parser regression. Added defense-in-depth guard: empty dict → null
  component score → slot drops out of weighted blend → coverage shrinks
  visibly rather than the dim looking healthy. Seeder validate already
  enforces ≥1 black + ≥12 grey so this can't reach production through
  the normal write path; the guard costs nothing and catches malformed
  payloads that bypass validation. New regression test pins.

P2 — bisLbsXborderPctGdp goalposts mismatch U-shape peak:
  Registry entry had `goalposts: { worst: 60, best: 15 }` implying a
  linear lowerBetter scale peaking at 15%. The U-shape function actually
  peaks at 25% (value=15% scores 87.5, not 100). Updated to
  `{ worst: 60, best: 25 }` (over-exposed branch, peak anchor) with an
  explicit comment that goalposts here are documentation-only — the
  actual scorer uses normalizeBandLowerBetter, not a generic linear
  normalizer. Tooling reading the registry should consult the scorer
  helper directly.

P2 (resolved differently than originally suggested) —
api/bootstrap.js missing 3 new data keys:
  Greptile flagged that AGENTS.md requires bootstrap hydration for new
  data sources. Verified the bootstrap-hydration-coverage test enforces
  this via a `getHydratedData` consumer requirement in src/.
  The 3 new keys (economic:wb-external-debt:v1, economic:bis-lbs:v1,
  economic:fatf-listing:v1) are SERVER-ONLY — they feed
  scoreFinancialSystemExposure inside /api/resilience/* handlers; no
  client panel consumes them directly. Adding them to bootstrap without
  a consumer would fail the parity tests. Documented in api/bootstrap.js
  as a deferred entry: "if a future PR adds a client panel that displays
  raw BIS LBS / FATF / WB external-debt data, register the keys here
  AND add the corresponding consumer + cache-keys.ts entries in the
  same PR."

Note on Greptile's P1 #1 (preflight `:v1` suffix mismatch): already
resolved in commit d904103 (uses `resolveSeedMetaKey` from
_dimension-freshness.ts). Greptile reviewed an earlier commit.

Quality gates:
  - npm run typecheck:api: clean
  - npm run lint + lint:md + version:check: clean
  - npm run test:data: 7156/7156 pass (was 7154; +2 regression guards)

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v3.0.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <[email protected]>
@koala73
koala73 force-pushed the feat/financial-system-exposure branch from 8b47322 to 29f8f3b Compare April 25, 2026 18:43
@koala73
koala73 merged commit 229a6a6 into main Apr 25, 2026
11 checks passed
@koala73
koala73 deleted the feat/financial-system-exposure branch April 25, 2026 18:48
koala73 added a commit that referenced this pull request Apr 25, 2026
…flow (financialSystemExposure activation) (#3412)

* fix(resilience): correct WB indicator + switch BIS LBS → BIS CBS dataflow

Two activation-time bugs caught when running the financialSystemExposure
seeders against production Redis (PR #3407 follow-up).

== Fix #1 — WB IDS short-term external debt indicator ==

Original code used `DT.DOD.DSTC.IR.ZS × DT.DOD.DECT.GN.ZS / 100`,
intended to compose "short-term external debt as % of GNI." But
`DT.DOD.DSTC.IR.ZS` is "% of total RESERVES" (NOT "% of total
external debt"). Argentina (164%), Turkey (114%), and Sri Lanka all
had intermediate ratios > 100% because their short-term debt exceeds
reserves — caught by Redis audit on the first live seed run.

Fix: divide absolute USD values directly.
  shortTermDebtPctGni = (DT.DOD.DSTC.CD / NY.GNP.MKTP.CD) × 100

Live verification: BR=4%, AR=7.77%, TR=13.26%, LK=6.03%, NG=9.05% —
all in plausible 0-15% range matching real-world short-term debt ratios.

== Fix #2 — BIS LBS → BIS CBS dataflow ==

Plan called for "BIS LBS by-parent" data, but `WS_LBS_D_PUB` only
exposes counterparty as the aggregate `5J`. Per-counterparty
breakdowns require `WS_CBS_PUB` (Consolidated Banking Statistics).

CBS: 11 dimensions (LBS had 12), parent is L_REP_CTY, CBS_BASIS=F
selects foreign-claims ultimate-risk basis. SDMX key:
  Q.S.<PARENT>.4B.F.C.A.A.TO1.A.

Live verification: 201 records, all 16 parents fetched, BR=19.6%,
CN=3.1%, AE=46.7%, SG=233%, CH=278% — values match real-world bank
exposure including U-shape penalty for hub jurisdictions.

Filename + Redis key (`economic:bis-lbs:v1`) retained for historical
continuity; scorer-side contract unchanged.

What's pending for full activation: FATF seeder still blocked by
HTTP 403 on fatf-gafi.org. Separate PR.

Quality gates:
  - typecheck:api / lint / lint:md: clean
  - npm run test:data: 7189/7189 pass

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v3.0.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <[email protected]>

* fix(resilience): address Greptile P2s on PR #3412 + hub-self-exclusion

Three fixes on top of the BIS LBS → CBS migration.

== Greptile #1 — BIS_AGGREGATE_CODES Set was dead code ==

The filter chain was:
  cpCode.length !== 2 || !/^[A-Z]{2}$/.test(cpCode) || BIS_AGGREGATE_CODES.has(cpCode)

Every entry in BIS_AGGREGATE_CODES had a digit (5J, 1C, A2, 4F, ...),
so all of them already failed the `/^[A-Z]{2}$/` regex BEFORE the Set
was consulted. The Set never caught anything in production.

Real risk the Set was supposed to mitigate: BIS introduces a 2-letter
ALL-ALPHA aggregate (e.g. `EU`) that the regex would pass. Replaced
with `ALPHA_AGGREGATE_CODES` — empty by default, audited each CBS
quarterly publish. Verified against the live `WS_CBS_PUB`
L_CP_COUNTRY codelist (252 values as of 2026-04-25): no current
2-letter all-alpha aggregates exist.

== Greptile #2 — combineLbsByCounterparty alias removed ==

`export { combineCbsByCounterparty as combineLbsByCounterparty }` made
the old LBS name a first-class export of the module. Any future code
importing `combineLbsByCounterparty` would silently get CBS data while
the name implied LBS semantics. Only the test file consumed it.

Removed the alias, renamed test imports to `combineCbsByCounterparty`.

== Self-noted finding — hub-country self-counting in Component 4 ==

Caught during the 2026-04-25 production activation audit. For
Singapore and Switzerland (both in PARENT_COUNTRIES AND on the
counterparty list), the BIS CBS payload included their own domestic
banking claims:
  SG-on-SG:  $584B
  CH-on-CH:  $2.2T

These are domestic loan books, NOT foreign-bank cross-border exposure.
Component 4 (`parentCount`) is supposed to measure "how many INDEPENDENT
FOREIGN USD-clearing routes remain if a major counterparty pulls" —
domestic banks aren't a foreign-fallback route.

Filter: drop entries where `cp === parent` when building
claimsByCpByParent. Live verification of the impact:

  Country  OLD pct      NEW pct      OLD parentCount  NEW parentCount
  SG       232.98%      126.11%      12               11
  CH       277.89%       43.69%      10                9
  US        ...          32.2%       (US-on-US excluded)
  GB        ...          65.96%      (GB-on-GB excluded)
  BR        19.62%       19.62%      4                 4 (unchanged; BR not in PARENT_COUNTRIES)
  AE        46.69%       46.69%      10               10 (unchanged; AE not in PARENT_COUNTRIES)

CH dropped from "Iceland-2008 territory" to "over-exposed" — the old
number was a measurement artifact. The new numbers reflect actual
foreign-bank exposure as the construct intends.

Tests: new "excludes self-claims" regression guard pins the SG/CH
behavior. Methodology doc gets a §"Self-exclusion rule" callout under
Component 4.

Quality gates:
  - typecheck:api / lint / lint:md: clean
  - npm run test:data: 7190/7190 pass (was 7189; +1 self-exclusion guard)
  - Live production seed run: 201 records, hub-self-exclusion verified

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v3.0.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context, extended thinking) <[email protected]>
koala73 added a commit that referenced this pull request Apr 25, 2026
…tches (#3413)

* fix(fatf-listing): Wayback Machine fallback for Cloudflare-blocked fetches

FATF's www.fatf-gafi.org enforces a Cloudflare "Just a moment…" JS
challenge that blocks both direct Node fetches and CONNECT-proxy
(Decodo) requests with HTTP 403. PR #3407's seeder consequently writes
nothing on every Railway tick — economic:fatf-listing:v1 shows
status=EMPTY in /api/health because the key is never published.

Direct probe today (2026-04-25): direct fetch with full Chrome browser
headers (Accept-Language, Referer, Sec-Fetch-*) still 403s from a
residential IP. The challenge requires JS execution, which neither
Node fetch nor httpsProxyFetchRaw provides. Header tweaks won't help.

Wayback Machine bypasses Cloudflare entirely (its crawler captures
through different infrastructure). CDX API has multiple status:200
snapshots in 2026 (most recent 2026-04-03 with current Feb-2026-plenary
content); FATF's 3x/year publishing cadence vs the seeder's 30-day
bundle interval and 42-day STALE_SEED threshold means Wayback's 1-3
day capture lag is irrelevant.

Three-tier fetch: direct -> CONNECT proxy -> Wayback. The first two
are preserved unchanged so this seeder transparently recovers the
moment FATF rotates off Cloudflare or whitelists Decodo egress.

Wayback fetch uses the id_ URL modifier (/web/<timestamp>id_/<url>),
which returns the original captured HTML byte-for-byte WITHOUT
Wayback's banner injection or href/src rewriting. The seeder's
existing parser (findPublicationLink, extractListedCountries) works
unchanged. A regression test pins the modifier to defend against
accidental cleanup that switches to the bare /web/timestamp/url form.

Tests: +7 cases on fetchViaWayback covering happy path, CDX/snapshot
HTTP errors, no-200-snapshots window, malformed timestamps, the id_
modifier pin, and the from-date floor in the CDX query.

Bundle interval, validate, and seed-meta wiring unchanged.

* fix(fatf-listing): use CDX limit=-1 to actually get the latest snapshot

PR #3413 review: with limit=20 the CDX query returned the OLDEST 20
captures within the from-date window (CDX default sort is timestamp-
ascending, positive `limit=N` = first N rows = oldest N). Picking
rows[length-1] then gave us the 20th-oldest capture, not the truly
most-recent. FATF accumulates well over 20 captures per 180-day
window so this would silently serve a stale archived list while a
newer snapshot existed — breaking the whole "latest FATF state via
Wayback" guarantee.

CDX accepts negative `limit` to mean "last N captures by timestamp".
`limit=-1` returns just the single most-recent capture, which is
exactly what we need and also avoids fetching ~20× more rows than we
use. Picking logic (rows[length-1]) is unchanged — with limit=-1 the
response is exactly [headerRow, mostRecentSnapshot] so length-1 still
points at the right row.

Test additions:
- Pin `limit=-1` in the URL-shape test (regression guard).
- Negative-assertion that no positive `limit=N` is present (defense
  against a future cleanup that "tidies up" the syntax and silently
  reverts to the bug).

* fix(fatf-listing): User-Agent header on Wayback CDX + snapshot fetches

Greptile P2 review finding on PR #3413. AGENTS.md:185 mandates
"Always include User-Agent header in server-side fetch calls".
The direct FATF fetch passes CHROME_UA but the Wayback fallback's
two fetches (CDX query + snapshot) omitted it, breaking convention.

archive.org doesn't currently block UA-less requests, but: (a)
house-style consistency is the point, (b) a future Wayback rate
limiter could reasonably enforce UA, (c) the direct fetch the
seeder makes to fatf-gafi.org sets CHROME_UA, so a Wayback
fallback that swaps out the UA is unexpected behavior.

Test: +1 case asserts both calls receive a User-Agent header
matching the canonical CHROME_UA pattern (Mozilla/5.0...). Pinned
strongly enough to catch a regression that drops the header OR
substitutes a placeholder/empty value.

Also addresses Greptile P1 (limit=20 oldest-not-newest), already
fixed in commit ab4797a -- Greptile's last-reviewed commit was
the pre-fix a4f254e.
koala73 added a commit that referenced this pull request Apr 26, 2026
…cher (review fixup on PR #3432)

Reviewer found one more WB seeder I missed in the original PR #3432 sweep:

**P1: `scripts/seed-wb-external-debt.mjs:69` had the exact same compound trap.**
`Number(record?.value)` before any null check, then year-based latest-picker
overwrites. Unlike the SWF imports picker (where `value <= 0` accidentally
catches Number(null)=0 because imports must be positive), this script's
downstream `combineExternalDebt` filter at `:98` only rejects negative
debt — `debt.value < 0`, not `<= 0`. So a `value: null` record from a
late-reporting LMIC (KW/QA/AE publish IDS data 1-2y behind G7) would
coerce to `0`, win the year comparison, and propagate as a false
`debtToReservesRatio: 0` → `0% short-term-debt-to-GNI` for the country.

This feeds `economic:wb-external-debt:v1` consumed by the new
`financialSystemExposure` dim (PR #3412 / #3407), so the false 0% would
silently inflate the dim's score for affected LMICs.

Fix: same recipe as PRs #3427 and #3432 — explicit
`if (record?.value == null) continue;` BEFORE `Number()` coercion.

**Defense-in-depth: `scripts/seed-bis-lbs.mjs:204` (the WB GDP fetcher
inside the BIS LBS seeder) also gets the explicit null-skip.**
This one was technically safe because GDP > 0 is a real-world invariant
and the `value <= 0` filter catches Number(null)=0 by side effect. But
per the lesson now codified in skill `wb-bulk-mrv1-null-coverage-trap`,
the protection is fragile — future indicator-family changes that
relax `value <= 0` would silently lose null protection. Adding the
explicit null-skip makes the picker null-safe regardless of filter
relaxation.

**End state after this commit + PR #3427 + PR #3432 + this fixup:**
ALL WB-bulk seeders in `scripts/seed-*.mjs` either (a) have an explicit
`value == null` skip BEFORE coercion, or (b) use a non-WB endpoint
shape (BIS SDMX, IMF SDMX, etc.) where the trap doesn't apply.
Final sweep recipe documented in the commit; next reviewer can run it
verbatim to audit any new seeder.

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

Co-Authored-By: Claude <[email protected]>
koala73 added a commit that referenced this pull request Apr 26, 2026
…rs (#3432)

* fix(resilience): apply mrv=5 + null-skip recipe to remaining 4 WB seeders

PR #3427 fixed the mrv=1 + Number(null)=0 compound trap in 2 recovery
seeders (external-debt + reserve-adequacy). A subsequent sweep
(`grep -lE "mrv=1[^0-9]" scripts/*.mjs`) found 4 OTHER seeders with
the same trap shape:

- `seed-fossil-electricity-share.mjs` (EG.ELC.FOSL.ZS)
- `seed-low-carbon-generation.mjs` (EG.ELC.{NUCL,RNEW,HYRO}.ZS)
- `seed-power-reliability.mjs` (EG.ELC.LOSS.ZS)
- `seed-sovereign-wealth.mjs` (NE.IMP.GNFS.CD via pickLatestPerCountry)

All four migrated to the established recipe:
1. mrv=1 → mrv=5 + per-country pickLatest
2. per_page=500 → per_page=2000 (5x records per country = need bigger page)
3. Explicit `if (record?.value == null) continue` BEFORE Number() coercion
4. Year sanity check + per-country latest comparison

**Why explicit null-skip matters more for the energy seeders:**
The 3 energy seeders use "% of" indicators (EG.ELC.{FOSL,NUCL,RNEW,
HYRO,LOSS}.ZS) where 0 IS a legitimate value (country has 0% nuclear,
0% fossil, perfect grid reliability, etc.). The accidental
null-protection trick `if (value <= 0) continue` from the SWF picker
WOULD WRONGLY DROP legitimate zeros for these. Must skip null
explicitly so the `value === 0` case can flow through to scoring.

**`seed-sovereign-wealth.mjs` was already mostly correct** (mrv=5 +
pickLatest helper + accidental null protection via `value <= 0` since
imports must be > 0). Added explicit null-skip as defense-in-depth so
a future copy-paste of `pickLatestPerCountry` for a different
indicator family doesn't silently lose null protection.

**Lineage:** Plan 2026-04-26-001 cohort dry-run → user audit Priority
Check #4 ("Replace mrv=1 with mrv=5 + latest non-null in ALL seeders")
→ this PR. Companion to PR #3427.

**Memory `wb-bulk-mrv1-null-coverage-trap`** was updated post-PR-#3427
with the explicit `Number(null) === 0` warning + production case
study — that memory is the recipe these 4 seeders now follow.

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

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

* fix(resilience): null-skip in seed-wb-external-debt + bis-lbs GDP fetcher (review fixup on PR #3432)

Reviewer found one more WB seeder I missed in the original PR #3432 sweep:

**P1: `scripts/seed-wb-external-debt.mjs:69` had the exact same compound trap.**
`Number(record?.value)` before any null check, then year-based latest-picker
overwrites. Unlike the SWF imports picker (where `value <= 0` accidentally
catches Number(null)=0 because imports must be positive), this script's
downstream `combineExternalDebt` filter at `:98` only rejects negative
debt — `debt.value < 0`, not `<= 0`. So a `value: null` record from a
late-reporting LMIC (KW/QA/AE publish IDS data 1-2y behind G7) would
coerce to `0`, win the year comparison, and propagate as a false
`debtToReservesRatio: 0` → `0% short-term-debt-to-GNI` for the country.

This feeds `economic:wb-external-debt:v1` consumed by the new
`financialSystemExposure` dim (PR #3412 / #3407), so the false 0% would
silently inflate the dim's score for affected LMICs.

Fix: same recipe as PRs #3427 and #3432 — explicit
`if (record?.value == null) continue;` BEFORE `Number()` coercion.

**Defense-in-depth: `scripts/seed-bis-lbs.mjs:204` (the WB GDP fetcher
inside the BIS LBS seeder) also gets the explicit null-skip.**
This one was technically safe because GDP > 0 is a real-world invariant
and the `value <= 0` filter catches Number(null)=0 by side effect. But
per the lesson now codified in skill `wb-bulk-mrv1-null-coverage-trap`,
the protection is fragile — future indicator-family changes that
relax `value <= 0` would silently lose null protection. Adding the
explicit null-skip makes the picker null-safe regardless of filter
relaxation.

**End state after this commit + PR #3427 + PR #3432 + this fixup:**
ALL WB-bulk seeders in `scripts/seed-*.mjs` either (a) have an explicit
`value == null` skip BEFORE coercion, or (b) use a non-WB endpoint
shape (BIS SDMX, IMF SDMX, etc.) where the trap doesn't apply.
Final sweep recipe documented in the commit; next reviewer can run it
verbatim to audit any new seeder.

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

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

* docs: fix stale 'mrv=1' top-comment in seed-low-carbon-generation.mjs

Reviewer follow-up on PR #3432: the top-of-file rationale comment in
`seed-low-carbon-generation.mjs:22` still claimed "We fetch the
most-recent value (mrv=1)" even though the actual fetch was migrated
to mrv=5 + null-skip in commit dd0be80. Non-blocking but worth
cleaning up — this is the same doc-drift pattern memory
`feedback_doc_drift_after_behavior_fix_needs_grep_sweep` warns about.

Updated the comment to describe the current mrv=5 + per-country
pickLatest behavior and reference the relevant skill + this PR.

The other mrv=1 references in PR #3432's touched files are intentional
— they appear inside rationale comments that explain the trap before
documenting why the seeder uses mrv=5 instead. Those should NOT change.

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

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

---------

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