feat(resilience): financialSystemExposure dim scaffold (Phase 2 Ship 2 — flag-gated dark)#3407
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR scaffolds the Two P1 issues need resolution before the flag is flipped ON:
Confidence Score: 3/5Safe 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.
Important Files Changed
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]
Reviews (1): Last reviewed commit: "feat(resilience): financialSystemExposur..." | Re-trigger Greptile |
|
|
||
| // 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. ' + |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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'; | ||
| } | ||
|
|
There was a problem hiding this comment.
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.
| // 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', |
There was a problem hiding this comment.
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.
| 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', |
There was a problem hiding this comment.
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.
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]>
…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]>
8b47322 to
29f8f3b
Compare
…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]>
…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.
…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]>
…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]>
Summary
Plan
2026-04-25-004Phase 2 (Ship 2) — scaffold only. Stacked on PR #3405 (Phase 1 Ship 1).Adds the new
financialSystemExposureresilience 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 (plan2026-04-24-001).This is a deliberate two-PR split:
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_ENABLEDenv flag defaultsfalse. When OFF:score=0, coverage=0, imputationClass=null— empty-data shapeWhen the flag is flipped ON in production (after seeders populate):
economic:wb-external-debt:v1,economic:bis-lbs:v1,economic:fatf-listing:v1)ResilienceConfigurationError(message, missingKeys)(two-arg form per Codex R3 P1 Add smart zoom visibility for dense map layers #2) → caught byscoreAllDimensions→ routed toimputationClass='source-failure'Cache prefix bumps (lockstep)
resilience:score:v13:→v14:resilience:ranking:v13→v14resilience: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.5inRESILIENCE_DIMENSION_WEIGHTSso 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)
_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)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/v9api/health.jsSEED_META + KEY_TO_DOMAIN,api/seed-health.jsSEED_DOMAINS — 3 new keys in BOTH (per memoryfeedback_two_health_endpoints_must_match)src/components/resilience-widget-utils.tslabel map ("Fin. Exposure"),src/components/ResilienceWidget.ts"19 dimensions" → "20 dimensions"docs/methodology/country-resilience-index.mdx— new#### Financial System ExposureH4 + indicator table + license attribution + cross-link to construct rationaletests/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 dimWhat this PR does NOT ship (deferred to follow-up PR)
scripts/seed-bis-lbs.mjs(BIS SDMXWS_LBS_D_PUBby-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)scripts/seed-bundle-macro.mjstests/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 FATFtradePolicyandfinancialSystemExposure; no double-counting betweenfinancialSystemExposureandliquidReserveAdequacyfinancialSystemExposure(gates the construct calibration)docs/methodology/financial-system-exposure.mdwith rejected alternatives + license attributionThese 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 0npm run typecheck:api— exit 0npm run test:data— 7115/7115 tests passtests/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 contracttests/resilience-cache-keys-health-sync.test.mts— parity test extended for v14 score+ranking, v9 history; asserts old prefixes absent in non-comment codetests/resilience-methodology-lint.test.mts— heading "Financial System Exposure" → dim idfinancialSystemExposuretests/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 dimnpm run lint(biome) — exit 0npm run lint:md— exit 0npm run version:check— exit 0resilience:(score|ranking):v13/resilience:history:v8literalsPost-Deploy Monitoring & Validation
[resilience]invocations after deploy — expect compute-on-miss for v14 score keys untilseed-resilience-scorescron warms them/api/healthresilienceRankingregistered keyresilience:ranking:v14RESILIENCE_FIN_SYS_EXPOSURE_ENABLEDerrors should appear; Sentry should be silent on the new dimredis-cli GET 'resilience:ranking:v14'returns a populated record afterseed-bundle-resiliencecron runsredis-cli GET 'resilience:score:v14:US'shape includesfinancialSystemExposuredim entry withscore=0, coverage=0, imputationClass=null(flag-off baseline)financialSystemExposure.coverage = 0; the dim is "structurally present, signal-dark"/api/healthreportsresilience:ranking:v14STALE > 12h after merge → Railway cron failing; checkseed-bundle-resiliencelogs and re-deploy seeder/api/resilience/rankingimmediately post-deploy → revert the PR; v13 reads still work since we didn't delete the seeder keyResilienceConfigurationErroron every scoreFinancialSystemExposure call → dim showsimputationClass='source-failure'for all countries → expected behavior, but indicates the flag flip was premature; flip OFF to revertActivation runbook (for after the seeder PR ships)
seed-bis-lbs,seed-fatf-listing,seed-wb-external-debtmanually viarailway runto populate RedisRESILIENCE_FIN_SYS_EXPOSURE_ENABLED=truein Vercel + Railway env configDEL resilience:score:v14:*+DEL resilience:ranking:v14seed-resilience-scores.mjsto bulk-warm v14 score keys with the new dim's actual signalresilience:score:v14:*for all 222 countriesfinancialSystemExposure. If they don't, the construct is calibrated wrong — revert the flag flip and retuneFLAG_GATED_DARK_DIMENSIONSallow-list entry intests/resilience-release-gate.test.mtsin 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