Commit Graph

599 Commits

Author SHA1 Message Date
Elie Habib
0cdfddc885 feat(gold): central-bank reserves via IMF IFS (PR C) (#3038)
* feat(gold): central-bank gold reserves via IMF IFS (PR C)

* fix(gold): prefer ounces indicator over USD in IMF IFS candidate list

* fix(gold): align seed-health interval with monthly IMF cadence + drop ALG dup

Review findings on PR #3038:
- api/seed-health.js: intervalMin was 1440 (1 day), which flags stale at
  2880min (48h) — contradicted health.js maxStaleMin=44640 (~31 days) and
  would false-alarm within 2 days on a monthly data source. Bumped to
  22320 so both endpoints agree at ~31 days.
- seed-gold-cb-reserves ISO3_NAMES: dropped duplicate ALG entry (World Bank
  variant); DZA is canonical ISO 3166-1 alpha-3 and stays.
2026-04-13 08:19:53 +04:00
Elie Habib
a8b85e52c8 feat(gold): SPDR GLD physical holdings flows (PR B) (#3037)
* feat(gold): SPDR GLD physical holdings flows (PR B)

* fix(gold): strip UTF-8 BOM from SPDR CSV header (greptile P2 #3037)
2026-04-13 08:04:22 +04:00
Elie Habib
ee66b6b5c2 feat(gold): Gold Intelligence v2 — positioning depth, returns, drivers (#3034)
* feat(gold): richer Gold Intelligence panel with positioning, returns, drivers

* fix(gold): restore leveragedFunds fields and derive P/S netPct in legacy fallback

Review catch on PR #3034:

1. seed-cot.mjs stopped emitting leveragedFundsLong/Short during the v2
   refactor, which would zero out the Leveraged Funds bars in the existing
   CotPositioningPanel on the next seed run. Re-read lev_money_* from the
   TFF rows and keep the fields on the output (commodity rows don't have
   this breakdown, stay at 0).
2. get-gold-intelligence legacy fallback hardcoded producerSwap.netPct to 0,
   meaning a pre-v2 COT snapshot rendered a neutral 0% Producer/Swap bar
   on deploy until seed-cot reran. Derive netPct from dealerLong/dealerShort
   (same formula as the v2 seeder). OI share stays 0 because open_interest
   wasn't captured pre-migration; clearly documented now.

Tests: added two regression guards (leveragedFunds preserved for TFF,
commodity rows emit 0 for those fields).

* fix(gold): make enrichment layer monitored and honest about freshness

Review catch on PR #3034:

- seed-commodity-quotes now writes seed-meta:market:gold-extended via
  writeExtraKeyWithMeta on every successful run. Partial / failed fetches
  skip BOTH the data write and the meta bump, so health correctly reports
  STALE_SEED instead of masking a broken Yahoo fetch with a green check.
- Require both gold (core) AND at least one driver/silver before writing,
  so a half-successful run doesn't overwrite healthy prior data with a
  degraded payload.
- Handler no longer stamps updatedAt with new Date() when the enrichment
  key is missing. Emits empty string so the panel's freshness indicator
  shows "Updated —" with a dim dot, matching reality — enrichment is
  missing, not fresh.
- Health: goldExtended entry in STANDALONE_KEYS + SEED_META (maxStaleMin
  30, matching commodity quotes), and seed-health.js advertises the
  domain so upstream monitors pick it up.

The panel already gates session/returns/drivers sections on presence, so
legacy panels without the enrichment layer stay fully functional.
2026-04-12 22:53:32 +04:00
Elie Habib
d19b32708c fix(country-brief): display bugs — slugs, self-imports, N/A, HS labels (#3032)
* fix(country-brief): display bugs — slugs, self-imports, N/A, HS labels (#2970)

Six user-facing display fixes that individually looked minor but together
eroded trust in the Country Brief panel.

1. Incorrect chokepoint attribution per supplier. Intra-regional pairs
   (e.g. Greek/Italian refined petroleum to Turkey) overlapped on long
   pass-through routes like gulf-europe-oil, attributing Hormuz and Bab
   el-Mandeb to Mediterranean trade. Added a coastSide-based filter: when
   exporter and importer share the same coast, transit chokepoints are
   restricted to a regional whitelist (e.g. med -> bosphorus, gibraltar,
   suez only).

2. Self-imports. Rows where partnerIso2 equals the importer ISO2 are now
   filtered out of Product Imports.

3. "N/A" supplier rows. Unresolved ISO2 codes (seeder emits partnerIso2
   = '' when a UN code does not map) are now dropped from the render
   instead of surfacing as "N/A" at 14-16% share.

4. Raw slug "hormuz_strait" in shock-scenario prose. buildAssessment()
   now resolves chokepoint IDs to their display names ("Strait of Hormuz",
   "Suez Canal", etc.) via a small local map.

5. Raw ISO2 "TR can bridge" in shock-scenario prose. buildAssessment()
   now uses Intl.DisplayNames to render country names, with a raw-code
   fallback if instantiation fails.

6. HS chapter numbers instead of sector names in Cost Shock table. The
   empty-skeleton branch of /api/supply-chain/v1/multi-sector-cost-shock
   was returning hs2Label = hs2 (raw code); it now uses
   MULTI_SECTOR_HS2_LABELS. Frontend also adds an HS2_SHORT_LABELS
   fallback so the table never shows raw codes even if hs2Label is empty.

All 4973 data-suite tests pass. Closes #2970.

* fix(country-brief): apply supplier filter to recommendations + empty state (#2970)

Address PR #3032 review (P2):
- The supplier filter (drop self-imports + unmapped ISO2) only reached
  the table; the recommendation pane still iterated the unfiltered
  enriched array, so hidden rows could still produce recommendation
  text and safeAlternative pointers.
- Build a single visibleEnriched list and use it for both the row table
  and the recommendation pane.
- Short-circuit to an explicit "No external suppliers in available
  trade data" empty state when filtering removes every row, so the
  detail area never goes silently blank.
- Skip safeAlternative suggestions that would point at filtered-out
  partners (self or unmapped).

* test(country-brief): defensive ISO2 assertion for ICU variation (#2970)

Address PR #3032 review (P2): CLDR behaviour for unrecognised 2-letter
codes like 'XZ' varies across ICU versions; allow either raw 'XZ' or
the resolved 'Unknown Region' form.
2026-04-12 22:41:44 +04:00
Elie Habib
3696aba2d1 fix(infra): sync health/bootstrap/cache-keys parity (4 keys + 6 DATA_KEYS + 5 SEED_META) (#3015)
* fix(infra): sync health/bootstrap/cache-keys parity (4 BOOTSTRAP_CACHE_KEYS + 6 DATA_KEYS + 5 SEED_META)

Audit found 4 bootstrap.js keys (consumerPrices*) missing from
cache-keys.ts BOOTSTRAP_CACHE_KEYS, 6 bootstrapped keys invisible
to health DATA_KEYS monitoring (cryptoSectors, ddosAttacks,
economicStress, insights, predictions, trafficAnomalies), and 5
bootstrapped keys with no SEED_META staleness detection
(cryptoSectors, ddosAttacks, economicStress, marketImplications,
trafficAnomalies). Keys without seed-meta writers (bisCredit,
bisExchange, giving, minerals, serviceStatuses, temporalAnomalies)
were verified as on-demand/derived and correctly skipped.

* fix(health): write seed-meta on empty DDoS/anomalies data

Prevents false STALE_SEED alerts when Cloudflare returns zero events.
Extracts writeSeedMeta() helper from writeExtraKeyWithMeta().

* fix(health): remove duplicate insights/predictions aliases, fix test regex

P1: insights/predictions duplicate newsInsights/predictionMarkets
P2: keyRe now captures non-versioned consumer-prices keys

* fix(health): add ddosAttacks/trafficAnomalies to EMPTY_DATA_OK_KEYS

Zero DDoS events or traffic anomalies is a valid quiet-period state,
not a critical failure.
2026-04-12 20:38:08 +04:00
Elie Habib
da01def264 fix(health): add DATA_KEYS entry for energyCrisisPolicies (#3014)
health.js had the SEED_META entry (line 304) but was missing the DATA_KEYS
entry, so the health endpoint never reported on the energy:crisis-policies:v1
key. Without this, empty data goes undetected.
2026-04-12 19:45:36 +04:00
Elie Habib
793d7df9dc feat(energy-crisis): add IEA 2026 Energy Crisis Policy Response Tracker panel and seeder (#3008) 2026-04-12 15:09:54 +04:00
Elie Habib
676331607a feat(resilience): three-pillar aggregation with penalized weighted mean (T2.3) (#2990)
* feat(resilience): three-pillar aggregation with penalized weighted mean (Phase 2 T2.3)

Wire real three-pillar scoring: structural-readiness (0.40), live-shock-exposure
(0.35), recovery-capacity (0.25). Add penalizedPillarScore formula with alpha=0.50
penalty factor for backtest tuning. Set recovery domain weight to 0.25 and
redistribute existing domain weights proportionally to sum to 1.0. Bump cache
keys v8 to v9. The penalized formula is exported and tested but overallScore
stays as the v1 domain-weighted sum until the flag flips in PR 10.

* fix(resilience): update test description v8 to v9 (#2990 review)

Test descriptions said "(v8)" but assertions check v9 cache keys.
2026-04-12 10:18:42 +04:00
Elie Habib
17e34dfca7 feat(resilience): recovery capacity pillar — 6 new dimensions + 5 seeders (Phase 2 T2.2b) (#2987)
* feat(resilience): recovery capacity pillar — 6 new dimensions + 5 seeders (Phase 2 T2.2b)

Add the recovery-capacity pillar with 6 new dimensions:
- fiscalSpace: IMF GGR_G01_GDP_PT + GGXCNL_G01_GDP_PT + GGXWDG_NGDP_PT
- reserveAdequacy: World Bank FI.RES.TOTL.MO
- externalDebtCoverage: WB DT.DOD.DSTC.CD / FI.RES.TOTL.CD ratio
- importConcentration: UN Comtrade HHI (stub seeder)
- stateContinuity: derived from WGI + UCDP + displacement (no new fetch)
- fuelStockDays: IEA/EIA (stub seeder, Enrichment tier)

Each dimension has a scorer in _dimension-scorers.ts, registry entries in
_indicator-registry.ts, methodology doc subsections, and fixture data.

Seeders: fiscal-space (real, IMF WEO), reserve-adequacy (real, WB API),
external-debt (real, WB API), import-hhi (stub), fuel-stocks (stub).

Recovery domain weight is 0 until PR 4 (T2.3) ships the penalized weighted
mean across pillars. The domain appears in responses structurally but does
not affect the overall score.

Bootstrap: STANDALONE_KEYS + SEED_META + EMPTY_DATA_OK_KEYS + ON_DEMAND_KEYS
all updated in api/health.js. Source-failure mapping updated for
stateContinuity (WGI adapter). Widget labels and LOCKED_PREVIEW updated.

All 282 resilience tests pass, typecheck clean, methodology lint clean.

* fix(resilience): ISO3→ISO2 normalization in WB recovery seeders (#2987 P1)

Both seed-recovery-reserve-adequacy.mjs and seed-recovery-external-debt.mjs
used countryiso3code from the World Bank API response then immediately
rejected codes where length !== 2. WB returns ISO3 codes (USA, DEU, etc.),
so all real rows were silently dropped and the feed was always empty.

Fix: import scripts/shared/iso3-to-iso2.json and normalize before the
length check. Also removed from EMPTY_DATA_OK_KEYS in health.js since
empty results now indicate a real failure, not a structural absence.

* fix(resilience): remove unused import + no-op overrides (#2987 review)

* fix(test): update release-gate to expect 6 domains after recovery pillar
2026-04-12 10:10:10 +04:00
Elie Habib
e070a97c3d Phase 3 PR2: Weekly regional briefs (LLM seeder + RPC) (#2989)
* feat(intelligence): weekly regional briefs (Phase 3 PR2)

Phase 3 PR2 of the Regional Intelligence Model. Adds LLM-powered
weekly intelligence briefs per region, completing the core feature set.

## New seeder: scripts/seed-regional-briefs.mjs

Standalone weekly cron script (not part of the 6h derived-signals bundle).
For each non-global region:
  1. Read the latest snapshot via two-hop Redis read
  2. Read recent regime transitions from the history log (#2981)
  3. Call the LLM once per region with regime trajectory + balance +
     triggers + narrative context
  4. Write structured brief to intelligence:regional-briefs:v1:weekly:{region}
     with 8-day TTL (survives one missed weekly run)

Reuses the same injectable-callLlm + parse-validation + provider-chain
pattern from narrative.mjs and weekly-brief.mjs.

## New module: scripts/regional-snapshot/weekly-brief.mjs

  generateWeeklyBrief(region, snapshot, transitions, opts?)
    -> { region_id, generated_at, period_start, period_end,
         situation_recap, regime_trajectory, key_developments[],
         risk_outlook, provider, model }

  buildBriefPrompt()    — pure prompt builder
  parseBriefJson()      — JSON parser with prose-extraction fallback
  emptyBrief()          — canonical empty shape

Global region is skipped. Provider chain: Groq -> OpenRouter. Validate
callback ensures only parseable responses pass (narrative.mjs PR #2960
review fix pattern).

## Proto + RPC: GetRegionalBrief

  proto/worldmonitor/intelligence/v1/get_regional_brief.proto

  - GetRegionalBriefRequest { region_id }
  - GetRegionalBriefResponse { brief: RegionalBrief }
  - RegionalBrief { region_id, generated_at, period_start, period_end,
                    situation_recap, regime_trajectory,
                    key_developments[], risk_outlook, provider, model }

## Server handler

  server/worldmonitor/intelligence/v1/get-regional-brief.ts

Simple getCachedJson read + adaptBrief snake->camel adapter.
Returns upstreamUnavailable: true on Redis failure so the gateway
skips caching (matching the get-regime-history pattern from #2981).

## Premium gating + cache tier

  src/shared/premium-paths.ts + server/gateway.ts RPC_CACHE_TIER

## Tests — 27 new unit tests

  buildBriefPrompt (5): region/balance/transitions/narrative rendered,
                        empty transitions handled, missing fields tolerated
  parseBriefJson (5): valid JSON, garbage, all-empty, cap at 5, prose extraction
  generateWeeklyBrief (6): success, global skip, LLM fail, garbage, exception,
                           period_start/end delta
  emptyBrief (2): region_id + empty fields
  handler (4): key prefix, adapter export, upstreamUnavailable, registration
  security (2): premium path + cache tier
  proto (3): RPC declared, import wired, RegionalBrief fields

## Verification

- npm run test:data: 4651/4651 pass
- npm run typecheck + typecheck:api: clean
- biome lint: clean

* fix(intelligence): address 3 review findings on #2989

P2 #1 — no consumer surface for GetRegionalBrief

Acknowledged. The consumer is the RegionalIntelligenceBoard panel,
which will call GetRegionalBrief and render a weekly brief block.
This wiring is Phase 3 PR3 (UI) scope — the RPC + Redis key are the
delivery mechanism, not the end surface. No code change in this commit;
the RPC is ready for the panel to consume.

P2 #2 — readRecentTransitions collapses failure to []

readRecentTransitions returned [] on Redis/network failure, which is
indistinguishable from a genuinely quiet week. The LLM then generates
a brief claiming "no regime transitions" when in reality the upstream
is down — fabricating false input.

Fix: return null on failure. The seeder skips the region with a clear
log message when transitions is null, so the brief is never written
with unreliable input. Empty array [] now only means genuinely no
transitions in the 7-day window.

P2 #3 — parseBriefJson accepts briefs the seeder rejects

parseBriefJson treated non-empty key_developments as valid even if
situation_recap was empty. The seeder gate only writes when
brief.situation_recap is truthy. That mismatch means the validator
pass + provider-fallback logic could accept a response that the seeder
then silently drops.

Fix: require situation_recap in parseBriefJson for valid=true, matching
the seeder gate. Now both checks agree on what constitutes a usable
brief, and the provider-fallback chain correctly falls through when
a provider returns a brief with developments but no recap.

* fix(intelligence): TTL path-segment fix + seed-meta always-write (Greptile P1+P2 on #2989)

P1 — TTL silently not applied (briefs never expire)

Upstash REST ignores query-string SET options (?EX=N). The correct
form is path-segment: /set/{key}/{value}/EX/{seconds}. Without this
fix every brief persists indefinitely and Redis storage grows
unboundedly across weekly runs.

P2 — seed-meta not written when all regions skipped

writeExtraKeyWithMeta was gated on generated > 0. If every region
was skipped (no snapshot yet, or LLM failed), seed-meta was never
written, making the seeder indistinguishable from "never ran" in
health tooling. Now writes seed-meta whenever failed === 0,
carrying regionsSkipped count.

P2 #3 (validate gate) — already fixed in previous commit (parseBriefJson
now requires situation_recap for valid=true).

* fix(intelligence): register regional-briefs in health.js SEED_META + STANDALONE_KEYS (review P2 on #2989)

* fix(intelligence): register regional-briefs in api/seed-health.js (review P2 on #2989)

* fix(intelligence): raise brief TTL to 15 days to cover missed weekly cycle (review P2 on #2989)

* fix(intelligence): distinguish missing-key from Redis-error + coverage-gated health (review P2s on #2989)

P2 #1 — false upstreamUnavailable before first seed

getCachedJson returns null for both "key missing" and "Redis failed",
so the handler was advertising an outage for every region before the
first weekly seed ran. Switched to getRawJson (throws on Redis errors)
so null = genuinely missing key → clean empty 200, and thrown error =
upstream failure → upstreamUnavailable: true for gateway no-store.

P2 #2 — partial run hides coverage loss in health

The seed-meta was written with generated count even if only 1 of 7
regions produced a brief. /api/health treats any positive recordCount
as healthy, so broad regional failure was invisible to operators.

Fix: recordCount is set to 0 when generated < ceil(expectedRegions/2).
This makes /api/health report EMPTY_DATA for severely partial runs
while still writing seed-meta (so the seeder is confirmed to have run).
coverageOk flag in the summary payload lets operators drill into the
exact coverage state.

* fix(intelligence): tighten coverage gate to expectedRegions-1 (review P2 on #2989)
2026-04-12 09:56:35 +04:00
Elie Habib
7dfdc819a9 Phase 0: Regional Intelligence snapshot writer foundation (#2940) 2026-04-11 17:55:39 +04:00
Elie Habib
46c35e6073 feat(breadth): add market breadth history chart (#2932) 2026-04-11 17:54:26 +04:00
Elie Habib
d3836ba49b feat(sentiment): add AAII investor sentiment survey (#2930)
* feat(sentiment): add AAII investor sentiment survey

Weekly bull/bear/neutral sentiment from AAII (1987-present). Shows
current reading, bull-bear spread, and 52-week historical chart.
Seeder fetches from AAII CSV, stores last 52 weeks in Redis.

* fix(aaii): wire panel loading + mark fallback data explicitly

* fix(aaii): keep panel live across refreshes + surface in health monitoring

- fetchData now falls back to /api/bootstrap?keys=aaiiSentiment on
  refresh (getHydratedData is one-shot and returns undefined after
  the first read, causing a permanent spinner on hourly refresh)
- Shows an error state with auto-retry when both hydrated and
  bootstrap-fetch miss, matching the WsbTickerScannerPanel pattern
- Registered aaiiSentiment in api/health.js BOOTSTRAP_KEYS and
  api/seed-health.js SEED_DOMAINS so rollout failures and
  fallback-only operation are observable in the monitoring dashboards

* fix(sentiment): handle BIFF8 SST trailing bytes and use UTC for AAII Thursday calc

Two P2 greptile fixes from PR #2930 review:

1. BIFF8 SST parser was reading the rich-text run count (cRun, flags & 0x08)
   and extended-string size (cbExtRst, flags & 0x04) to advance past those
   header fields, but never skipped the trailing bytes AFTER the char data:
   4 * cRun formatting-run bytes and cbExtRst ext-rst bytes. If any string
   before the column header was rich-text formatted, every subsequent SST
   entry parsed from the wrong offset, silently breaking XLS extraction and
   falling back to HTML scraping.

2. parseHtmlSentiment() computed last-Thursday via today.getDay() +
   setDate(today.getDate() - daysToThursday), both local-TZ-dependent. On
   Railway (non-UTC TZ) the inferred Thursday could drift by a day, causing
   the HTML-derived row to mismatch the XLS historical rows. Switched to
   getUTCDay() + Date.UTC() for TZ-stable arithmetic.
2026-04-11 17:05:39 +04:00
Elie Habib
d1cb0e3c10 feat(sectors): add P/E valuation benchmarking to sector heatmap (#2929)
* feat(sectors): add P/E valuation benchmarking to sector heatmap

Trailing/forward P/E, beta, and returns for 12 sector ETFs from Yahoo
Finance. Horizontal bar chart color-coded by valuation level plus
sortable table. Extends existing sector data pipeline.

* fix(sectors): clear stale valuations on empty refresh + document cache behavior

* fix(sectors): force valuation rollout for cached + breaker-persisted bootstraps

- Bumped market:sectors bootstrap key v1 -> v2 so stale 24h slow-tier
  payloads without the new valuations field are invisible to returning
  users on next page load
- Versioned the fetchSectors circuit-breaker (name -> "Sector Summary v2")
  so old localStorage/IndexedDB entries predating this PR cannot be
  returned as stale via the SWR path
- shouldCache now requires the valuations field to be present on the
  cached response, not just a non-empty sectors array
- loadMarkets no longer clears the valuations tab when a hydrated or
  fresh payload lacks the field; prior render is left intact, matching
  the finding's requirement
- Defensive check: hydrated payloads without valuations fall through to
  a live fetch instead of rendering an empty valuations tab

* fix(stocks): correct beta3Year source and null YTD color in sector P/E view

- scripts/ais-relay.cjs: beta3Year lives on defaultKeyStatistics (ks),
  not summaryDetail (sd); the previous fallback was a silent no-op.
- src/components/MarketPanel.ts: null ytdReturn now renders with
  var(--text-dim) instead of var(--red); the '--' placeholder no
  longer looks like a loss.

Addresses greptile review on PR #2929.
2026-04-11 16:51:35 +04:00
Elie Habib
af4502c21f feat(supply-chain): multi-sector cost shock calculator with closure duration slider (#2936) 2026-04-11 09:40:53 +04:00
Elie Habib
ea900679c3 feat: Comtrade bilateral HS4 seeder and product imports UI (#2921)
* feat(seed): Comtrade bilateral HS4 seeder for 197 countries x 20 products

Add seed-comtrade-bilateral-hs4.mjs that fetches bilateral import data
from UN Comtrade public API at HS4 product level. For each of 197
countries, fetches top 5 exporters per product across 20 strategic HS4
codes (energy, semiconductors, vehicles, pharma, food, etc.).

Includes:
- Rate-limited fetching (3.5s between requests, 60s retry on 429)
- Redis pipeline writes with 72h TTL
- Lock/TTL-extension patterns matching gold standard
- New Vercel edge function api/supply-chain/v1/country-products.ts
  (PRO-gated, reads from Upstash Redis)
- fetchCountryProducts() service function with premiumFetch auth

* feat(deep-dive): product imports section with supplier concentration data

Add a PRO-gated "Product Imports" card to CountryDeepDivePanel that
shows top imported products (HS4) with supplier breakdown. Includes a
searchable product selector dropdown, per-product exporter table with
share bars, and value formatting.

Wired into fetchProSections in country-intel.ts so data loads lazily
when a PRO user opens a country deep dive panel.

* feat(seed): use authenticated Comtrade API with key rotation

Switch bilateral HS4 seeder from public preview endpoint to the
authenticated data endpoint (/data/v1/get/) when COMTRADE_API_KEYS
env var is set. Rotates between comma-separated keys on each request
for higher throughput (~500 req/hour per key). Reduces inter-request
delay from 3.5s to 1.5s in authenticated mode. Falls back to the
public preview endpoint when no keys are configured.

* fix(supply-chain): private cache on PRO endpoint, skip writes on fetch failure

Two review fixes for PR #2921:

1. country-products.ts: Change Cache-Control from public to private
   with Vary: Authorization, Cookie to prevent CDN/CF from serving
   PRO data to non-PRO callers. Empty-data path uses no-store.

2. seed-comtrade-bilateral-hs4.mjs: On per-country fetch failure,
   skip the Redis SET entirely so stale-but-valid data is preserved.
   Previously the catch block swallowed the error and the code below
   still wrote products:[] to Redis, erasing last-known-good data.

* test(supply-chain): add bilateral HS4 seeder and product imports tests

Static analysis tests covering:
- Edge endpoint (country-products.ts): edge config, method guard, iso2 validation,
  PRO gating via isCallerPremium, Cache-Control/Vary headers, Upstash reads
- Seeder (seed-comtrade-bilateral-hs4.mjs): distributed lock, isMain guard, key
  rotation, TTL, 20 HS4 codes, no-write-on-failure, 429 retry, seed-meta
- Service (supply-chain/index.ts): type exports, premiumFetch usage, graceful fallback
- Panel (CountryDeepDivePanel.ts): updateProductImports, search/filter, PRO gate,
  textContent (no innerHTML), resetPanelContent cleanup, sectionCard usage

* fix(supply-chain): guard empty product writes, log retry failures, align interface

1. Skip Redis SET when groupByProduct returns empty (prevents silent
   API failures like HTTP 500 from overwriting last-known-good data).
2. Log HTTP status on 429 retry failure for observability.
3. Add partnerCode to ProductExporter interface to match Redis shape.
2026-04-11 07:12:35 +04:00
Elie Habib
2decda6508 feat(wsb): add Reddit/WSB ticker scanner seeder + bootstrap registration (#2916)
* feat(wsb): add Reddit/WSB ticker scanner seeder + bootstrap registration

Seeder in ais-relay.cjs fetches from r/wallstreetbets, r/stocks,
r/investing every 10min. Extracts ticker mentions, validates against
known ticker set, aggregates by frequency and engagement, writes top 50
to intelligence:wsb-tickers:v1.

4-file bootstrap registration: cache-keys.ts, bootstrap.js, health.js
with SEED_META maxStaleMin=30.

* fix(wsb): remove duplicate CEO + fix avgUpvoteRatio divisor

* fix(wsb): require ticker validation set + condition seed-meta on write + add seed-health

1. Skip seed when ticker validation set is empty (cold start/bootstrap miss)
2. Only write seed-meta after successful canonical write
3. Register in api/seed-health.js for dedicated monitoring

* fix(wsb): case-insensitive $ticker matching + BRK.B dotted symbol support

* fix(wsb): split $-prefixed vs bare ticker extraction + BRK.B→BRK-B normalization

1. $-prefixed tickers ($nvda, $BRK.B) skip whitelist validation (strong
   signal) — catches GME, AMC, PLTR etc. not in the narrow market watchlist
2. Bare uppercase tokens still validated against known set (high false-positive)
3. BRK.B normalized to BRK-B before validation (dot→dash)
4. Empty known set no longer skips seed — $-prefixed tickers still extracted

* fix(wsb): skip bare-uppercase branch entirely when ticker set unavailable
2026-04-11 07:07:11 +04:00
Elie Habib
a742537ae5 feat(supply-chain): Sprint D — GetSectorDependency RPC + vendor route-intelligence API + webhooks (#2905)
* feat(supply-chain): Sprint D — GetSectorDependency RPC + vendor route-intelligence API + webhooks

* fix(supply-chain): move bypass-corridors + chokepoint-registry to server/_shared to fix api/ boundary violations

* fix(supply-chain): webhooks — persist secret, fix sub-resource routing, add ownership check

* fix(supply-chain): address PR #2905 review findings

- Use SHA-256(apiKey) for ownerTag instead of last-12-chars (unambiguous ownership)
- Implement GET /api/v2/shipping/webhooks list route via per-owner Redis Set index
- Tighten SSRF: https-only, expanded metadata hostname blocklist, document DNS rebinding edge-runtime limitation
- Fix get-sector-dependency.ts stale src/config/ imports → server/_shared/ (Greptile P1)

* fix(supply-chain): getSectorDependency returns blank primaryChokepointId for landlocked countries

computeExposures() previously mapped over all of CHOKEPOINT_REGISTRY even
when nearestRouteIds was empty, producing a full array of score-0 entries
in registry insertion order. The caller's exposures[0] then picked the
first registry entry (Suez) as the "primary" chokepoint despite
primaryChokepointExposure = 0. LI, AD, SM, BT and other landlocked
countries were all silently assigned a fake chokepoint.

Fix: guard at the top of computeExposures() -- return [] when input is
empty so primaryChokepointId stays '' and primaryChokepointExposure stays 0.
2026-04-10 17:12:29 +04:00
Elie Habib
ac8e0de1ca fix(notifications): move entitlement check to POST-only, allow GET without gating (#2898)
getEntitlements() is fail-closed: returns null on Redis miss + Convex
fallback failure, causing 403 even for PRO users. This blocked the GET
path that reads channel/rule data, making the UI show all channels as
"Not connected" despite working delivery.

GET (reading own channels) now only requires auth, not entitlement.
POST (creating/modifying channels and rules) still requires PRO tier.
2026-04-10 15:12:30 +04:00
Elie Habib
12ea629a63 feat(supply-chain): Sprint C — scenario engine (templates, job API, Railway worker, map activation) (#2890)
* feat(supply-chain): Sprint C — scenario engine templates, job API, Railway worker, map activation

Adds the async scenario engine for supply chain disruption modelling:

- src/config/scenario-templates.ts: 6 pre-built ScenarioTemplate definitions
  (Taiwan Strait closure, Suez+BaB simultaneous, Panama drought, Hormuz blockade,
  Russia Baltic grain suspension, US electronics tariff shock) with costShockMultiplier
  and optional HS2 sector scoping. Exports ScenarioVisualState + ScenarioResult
  types (no UI imports, avoids MapContainer <-> DeckGLMap circular dep).

- api/scenario/v1/run.ts: PRO-gated edge function — validates scenarioId against
  template registry and iso2 format, enqueues job to Redis scenario-queue:pending
  via RPUSH. Returns {jobId, status:'pending'} HTTP 202.

- api/scenario/v1/status.ts: Edge function — validates jobId via regex to prevent
  Redis key injection, reads scenario-result:{jobId}. Returns {status:'pending'}
  when unprocessed, or full worker result when done.

- scripts/scenario-worker.mjs: Always-on Railway worker using BLMOVE LEFT RIGHT for
  atomic FIFO dequeue+claim. Idempotency check before compute. Writes result with
  24h TTL; writes {status:'failed'} on error; always cleans processing list in finally.

- DeckGLMap.ts: scenarioState field + setScenarioState(). createTradeRoutesLayer()
  overrides arc color to orange for segments whose route waypoints intersect scenario
  disruptedChokepointIds. Null state restores normal colors.

- MapContainer.ts: activateScenario(id, result) and deactivateScenario() broadcast
  ScenarioVisualState to DeckGLMap. Globe/SVG deferred to Sprint D (best-effort).

🤖 Generated with Claude Sonnet 4.6 via Claude Code (https://claude.com/claude-code) + Compound Engineering v2.49.0

Co-Authored-By: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>

* fix(supply-chain): move scenario-templates to server/ to satisfy arch boundary

api/ edge functions may not import from src/ app code. Move the authoritative
scenario-templates.ts to server/worldmonitor/supply-chain/v1/ and replace
src/config/scenario-templates.ts with a type-only re-export for src/ consumers.

* fix(supply-chain): guard scenario-worker runWorker() behind isMain check

Without the isMain guard, the pre-push hook picks up scenario-worker.mjs
as a seed test candidate (non-matching lines pass through sed unchanged)
and starts the long-running worker process, causing push failures.

* fix(pre-push): filter non-matching lines from seed test selector

The sed transform passes non-matching lines (e.g. scenario-worker.mjs)
through unchanged. Adding grep "^tests/" ensures only successfully
transformed test paths are passed to the test runner.

* fix(supply-chain): address PR #2890 review findings — worker data shapes + status PRO gate

Three bugs found in PR #2890 code review:

1. [High] scenario-worker.mjs read wrong cache shape for exposure data.
   supply-chain:exposure:{iso2}:{hs2}:v1 caches GetCountryChokepointIndexResponse
   ({ iso2, hs2, exposures: [{chokepointId, exposureScore}], ... }), not a
   chokepointId-keyed object. Worker now iterates data.exposures[], filters by
   template.affectedChokepointIds, and ranks by exposureScore (importValue does
   not exist on ChokepointExposureEntry). adjustedImpact = exposureScore x
   (disruptionPct/100) x costShockMultiplier.

2. [Medium] api/scenario/v1/status.ts was not PRO-gated, allowing anyone with
   a valid jobId to retrieve full premium scenario results. Added isCallerPremium()
   check; returns HTTP 403 for non-PRO callers, matching run.ts behavior.

3. [Low] Worker parsed chokepoint status cache as Array but actual shape is
   { chokepoints: [], fetchedAt, upstreamUnavailable }. Fixed to access
   cpData.chokepoints array.

* fix(scenario): per-country impactPct + O(1) route lookup in arc layer

- impactPct now reflects each country's relative share of the worst-hit
  country (0-100) instead of the flat template.disruptionPct for all
- Pre-build routeId→waypoints Map in createTradeRoutesLayer() so
  getColor() is O(1) per segment instead of O(n) per frame

* fix(scenario): rate limit, pipeline GETs, error sanitization, processing state, orphan drain

- Add per-user rate limit (10 jobs/min) + queue depth cap to run.ts
- Replace 594 sequential Redis GETs with single Upstash pipeline call
- Sanitize worker err.message to 'computation_error' in failed results
- Remove dead validateApiKey() calls (isCallerPremium covers this)
- Write processing state before computeScenario() starts
- Add SIGTERM handler + startup orphan drain to worker loop
- Validate dequeued job payload fields before use as Redis key fragments
- Fix maxImpact divide-by-zero with Math.max(..., 1)
- Hoist routeWaypoints Map to module level in DeckGLMap
- Add GET /api/scenario/v1/templates discovery endpoint
- Fix template sync comment to reference correct authoritative file

* docs(plan): mark Sprint C complete, record deferrals to Sprint D

- Sprint status table added: Sprints 0-2 merged, C ready to merge (#2890), A/B/D not started
- Sprint C checklist: 4 ACs checked off, panel UI + tariff-shock visual deferred
- Sprint D section updated to carry over Sprint C visual deferrals
- PR #2890 added to Related PRs

---------

Co-authored-by: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>
2026-04-10 14:44:14 +04:00
Elie Habib
12203a4f51 feat(notifications): generic webhook channel (Phase 3) (#2887)
* feat(notifications): generic webhook channel (Phase 3)

Add webhook as a 5th notification channel type. Users provide an HTTPS
URL, WorldMonitor POSTs structured JSON payloads to it. Enables
integration with Zapier, n8n, IFTTT, and custom pipelines.

Schema: webhook variant in notificationChannels with webhookEnvelope
(AES-256-GCM encrypted URL), webhookLabel, webhookSecret fields.

Relay: sendWebhook() with SSRF protection (DNS resolve + private IP
check), HTTPS-only enforcement, auto-deactivation on 404/410/403.

Digest cron: sendWebhook() delivers digest as structured JSON with
stories array, AI summary, and story count.

Requires Convex deploy for schema changes.

* fix(notifications): webhook UI, label persistence, SSRF fail-closed

Address review findings on PR #2887:

1. Add webhook to settings UI: channel row with URL input, label field,
   connect/cancel/save buttons, icon, and connected state display
2. Forward webhookLabel through edge function -> Convex relay -> mutation,
   persist in notificationChannels table (was silently discarded)
3. Fix digest sendWebhook SSRF: dns.resolve4().catch(()=>[]) fails open
   on IPv6-only hosts; now fails closed like the relay version

* fix(notifications): validate webhook URL at connect time + add webhookLabel to public mutation

1. Edge function now validates webhook URLs before encrypting: HTTPS required,
   private/local hostnames rejected (localhost, 127.*, 10.*, 192.168.*, etc.)
   Invalid URLs caught at connect time rather than silently failing on delivery.
2. Public setChannel mutation now accepts and persists webhookLabel,
   matching the internal mutation and schema.

* fix(notifications): include held alert details in webhook quiet-hours batch

Webhook batch_on_wake delivery now sends full alert details (eventType,
severity, title per alert) instead of just the batch subject line,
matching the information density of Slack/Discord/Email delivery.
2026-04-09 23:22:44 +04:00
Elie Habib
ce30a48664 feat(resilience): add rankStable flag to ranking items (#2879)
* feat(resilience): add rankStable flag to ranking items

Countries with score interval width <= 8 (p95-p05) are flagged as
rankStable=true, indicating robust ranking under weight perturbation.
Read from batch-computed intervals in Redis.

* fix(resilience): guard inverted intervals + scope fetch to scored countries

1. isRankStable rejects negative width (malformed p05 > p95)
2. fetchIntervals scoped to cachedScores.keys() instead of all countries

* fix(resilience): raw key read for intervals + bump ranking cache to v8

* fix(resilience): remove duplicate ScoreInterval interface after rebase

ScoreInterval is now generated in service_server.ts (from PR #2877).
Remove the local duplicate and re-export the generated type.
2026-04-09 22:34:36 +04:00
Elie Habib
1af73975b9 feat(energy): SPR policy classification layer (#2881)
* feat(energy): add SPR policy classification layer with 66-country registry

Static JSON registry classifying strategic petroleum reserve regimes for
66 countries (all IEA members + major producers/consumers). Integrates
into energy profile handler, shock model limitations, analyst context,
spine seeder, and CDP UI.

- scripts/data/spr-policies.json: 66-entry registry with regime, source, asOf
- scripts/seed-spr-policies.mjs: seeder following chokepoint-baselines pattern
- Proto fields 51-59 on GetCountryEnergyProfileResponse
- Handler reads SPR registry from Redis, populates proto fields
- Shock model adds fuel-mode-gated SPR limitations for non-IEA gov SPR
- Analyst context refactored to accumulator pattern (IEA + SPR parts)
- CDP UI: SPR badge for non-IEA government_spr, muted text for spare_capacity
- Spine integration: SPR fields in shockInputs + hasSprPolicy coverage flag
- Cache keys, health, bootstrap, seed-health registrations
- Tests: registry shape, ISO2, regime enum, required entries, no estimatedFillPct

* fix(energy): remove SPR from bootstrap (server-only); narrow SPR hasAny gate to renderable regimes

* feat(energy): render "no known SPR" risk note for countries with regime=none

* fix(energy): human-readable SPR regime labels; parallelize spine+registry reads in analyst
2026-04-09 22:16:24 +04:00
Elie Habib
0a1b74a9b2 feat(resilience): add score confidence intervals via batch Monte Carlo (#2877)
* feat(resilience): add score confidence intervals via batch Monte Carlo

Weekly cron perturbs domain weights ±10% across 100 draws per country,
stores p05/p95 in Redis. Score handler reads intervals and includes
them in the API response as ScoreInterval { p05, p95 }.

Proto field 14 (score_interval) added to GetResilienceScoreResponse.

* chore: regenerate proto types and OpenAPI docs for ScoreInterval

* fix(resilience): add seed-meta + lock + fix interval cache + percentile formula

1. Write seed-meta:resilience:intervals for health monitoring
2. Add distributed lock to prevent concurrent cron overlap
3. Move scoreInterval read outside 6h score cache boundary
4. Fix percentile index from floor to ceil-1 (nearest-rank)

* fix(health): add resilience:intervals to health + seed-health registries

* fix(seed): skip seed-meta on no-op runs + register intervals in health check
2026-04-09 22:06:54 +04:00
Elie Habib
fa64e2f61f feat(notifications): AI-enriched digest delivery (#2876)
* feat(notifications): AI-enriched digest delivery (Phase 1)

Add personalized LLM-generated executive summaries to digest
notifications. When AI_DIGEST_ENABLED=1 (default), the digest cron
fetches user preferences (watchlist, panels, frameworks), generates a
tailored intelligence brief via Groq/OpenRouter, and prepends it to the
story list in both text and HTML formats.

New infrastructure:
- convex/userPreferences: internalQuery for service-to-service access
- convex/http: /relay/user-preferences endpoint (RELAY_SHARED_SECRET auth)
- scripts/lib/llm-chain.cjs: shared Ollama->Groq->OpenRouter provider chain
- scripts/lib/user-context.cjs: user preference extraction + LLM prompt formatting

AI summary is cached (1h TTL) per stories+userContext hash. Falls back
to raw digest on LLM failure (no regression). Subject line changes to
"Intelligence Brief" when AI summary is present.

* feat(notifications): per-user AI digest opt-out toggle

AI executive summary in digests is now optional per user via
alertRules.aiDigestEnabled (default true). Users can toggle it off in
Settings > Notifications > Digest > "AI executive summary".

Schema: added aiDigestEnabled to alertRules table
Backend: Convex mutations, HTTP relay, edge function all forward the field
Frontend: toggle in digest settings section with descriptive copy
Digest cron: skips LLM call when rule.aiDigestEnabled === false

* fix(notifications): address PR review — cache key, HTML replacement, UA

1. Add variant to AI summary cache key to prevent cross-variant poisoning
2. Use replacer function in html.replace() to avoid $-pattern corruption
   from LLM output containing dollar amounts ($500M, $1T)
3. Use service UA (worldmonitor-llm/1.0) instead of Chrome UA for LLM calls

* fix(notifications): skip AI summary without prefs + fix HTML regex

1. Return null from generateAISummary() when fetchUserPreferences()
   returns null, so users without saved preferences get raw digest
   instead of a generic LLM summary
2. Fix HTML replace regex to match actual padding value (40px 32px 0)
   so the executive summary block is inserted in email HTML

* fix(notifications): channel check before LLM, omission-safe aiDigest, richer cache key

1. Move channel fetch + deliverability check BEFORE AI summary generation
   so users with no verified channels don't burn LLM calls every cron run
2. Only patch aiDigestEnabled when explicitly provided (not undefined),
   preventing stale frontend tabs from silently clearing an opt-out
3. Include severity, phase, and sources in story hash for cache key
   so the summary invalidates when those fields change
2026-04-09 21:35:26 +04:00
Elie Habib
6e401ad02f feat(supply-chain): Global Shipping Intelligence — Sprint 0 + Sprint 1 (#2870)
* feat(supply-chain): Sprint 0 — chokepoint registry, HS2 sectors, war_risk_tier

- src/config/chokepoint-registry.ts: single source of truth for all 13
  canonical chokepoints with displayName, relayName, portwatchName,
  corridorRiskName, baselineId, shockModelSupported, routeIds, lat/lon
- src/config/hs2-sectors.ts: static dictionary for all 99 HS2 chapters
  with category, shockModelSupported (true only for HS27), cargoType
- server/worldmonitor/supply-chain/v1/_chokepoint-ids.ts: migrated to
  derive CANONICAL_CHOKEPOINTS from chokepoint-registry; no data duplication
- src/config/geo.ts + src/types/index.ts: added chokepointId field to
  StrategicWaterway interface and all 13 STRATEGIC_WATERWAYS entries
- src/components/MapPopup.ts: switched chokepoint matching from fragile
  name.toLowerCase() to direct chokepointId === id comparison
- server/worldmonitor/intelligence/v1/_shock-compute.ts: migrated from old
  IDs (hormuz/malacca/babelm) to canonical IDs (hormuz_strait/malacca_strait/
  bab_el_mandeb); same for CHOKEPOINT_LNG_EXPOSURE
- proto/worldmonitor/supply_chain/v1/supply_chain_data.proto: added
  WarRiskTier enum + war_risk_tier field (field 16) on ChokepointInfo
- get-chokepoint-status.ts: populates warRiskTier from ChokepointConfig.threatLevel
  via new threatLevelToWarRiskTier() helper (FREE field, no PRO gate)

* feat(supply-chain): Sprint 1 — country chokepoint exposure index + sector ring

S1.1: scripts/shared/country-port-clusters.json
  ~130 country → {nearestRouteIds, coastSide} mappings derived from trade route
  waypoints; covers all 6 seeded Comtrade reporters plus major trading nations.

S1.2: scripts/seed-hs2-chokepoint-exposure.mjs
  Daily cron seeder. Pure computation — reads country-port-clusters.json,
  scores each country against CHOKEPOINT_REGISTRY route overlap, writes
  supply-chain:exposure:{iso2}:{hs2}:v1 keys + seed-meta (24h TTL).

S1.3: RPC get-country-chokepoint-index (PRO-gated, request-varying)
  - proto: GetCountryChokepointIndexRequest/Response + ChokepointExposureEntry
  - handler: isCallerPremium gate; cachedFetchJson 24h; on-demand for any iso2
  - cache-keys.ts: CHOKEPOINT_EXPOSURE_KEY(iso2, hs2) constant
  - health.js: chokepointExposure SEED_META entry (48h threshold)
  - gateway.ts: slow-browser cache tier
  - service client: fetchCountryChokepointIndex() exported

S1.4: Chokepoint popup HS2 sector ring chart (PRO-gated)
  Static trade-sector breakdown (IEA/UNCTAD estimates) per 9 major chokepoints.
  SVG donut ring + legend shown for PRO users; blurred lockout + gate-hit
  analytics for free users. Wired into renderWaterwayPopup().

🤖 Generated with Claude Sonnet 4.6 via Claude Code (https://claude.com/claude-code) + Compound Engineering v2.49.0

Co-Authored-By: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>

* fix(tests): update energy-shock-v2 tests to use canonical chokepoint IDs

CHOKEPOINT_EXPOSURE and CHOKEPOINT_LNG_EXPOSURE keys were migrated from
short IDs (hormuz, malacca, babelm) to canonical registry IDs
(hormuz_strait, malacca_strait, bab_el_mandeb) in Sprint 0.
Test fixtures were not updated at the time; fix them now.

* fix(tests): update energy-shock-seed chokepoint ID to canonical form

VALID_CHOKEPOINTS changed to canonical IDs in Sprint 0; the seed test
that checks valid IDs was not updated alongside it.

* fix(cache-keys): reword JSDoc comment to avoid confusing bootstrap test regex

The comment "NOT in BOOTSTRAP_CACHE_KEYS" caused the bootstrap.test.mjs
regex to match the comment rather than the actual export declaration,
resulting in 0 entries found. Rephrase to "excluded from bootstrap".

* fix(supply-chain): address P1 review findings for chokepoint exposure index

- Add get-country-chokepoint-index to PREMIUM_RPC_PATHS (CDN bypass)
- Validate iso2/hs2 params before Redis key construction (cache injection)
- Fix seeder TTL to 172800s (2× interval) and extend TTL on skipped lock
- Fix CHOKEPOINT_EXPOSURE_SEED_META_KEY to match seeder write key
- Render placeholder sectors behind blur gate (DOM data leakage)
- Document get-country-chokepoint-index in widget agent system prompts

* fix(lint): resolve Biome CI failures

- Add biome.json overrides to silence noVar in HTML inline scripts,
  disable linting for public/ vendor/build artifacts and pro-test/
- Remove duplicate NG and MW keys from country-port-clusters.json
- Use import attributes (with) instead of deprecated assert syntax

* fix(build): drop JSON import attribute — esbuild rejects `with` syntax

---------

Co-authored-by: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>
2026-04-09 17:06:03 +04:00
Elie Habib
6a87cf9530 feat(energy): bootstrap oilStocksAnalysis hydration + LNG vulnerability UI (#2854)
* feat(energy): bootstrap oilStocksAnalysis hydration + LNG vulnerability UI (V6-1)

Register oilStocksAnalysis and lngVulnerability in BOOTSTRAP_CACHE_KEYS
(both tiers: slow). Wire getHydratedData fast-path for oil stocks analysis,
add fetchLngVulnerability() with bootstrap fallback, and render a top-5
LNG-dependent countries table in EnergyComplexPanel.

* fix(energy): multiply LNG share by 100 for display; remove bootstrap-refetch fallback

* fix(energy): use String() instead of toLocaleString() for LNG imports display
2026-04-09 13:06:24 +04:00
Elie Habib
bc33fe955a fix(energy): orphaned cleanup + dataAvailable docs (V6-5) (#2851)
* fix(energy): remove orphaned chokepointTransits bootstrap; document dataAvailable semantics (V6-5)

* test(energy): remove chokepointTransits assertions from supply-chain-v2 tests
2026-04-09 12:46:24 +04:00
Elie Habib
75e9c22dd3 feat(resilience): populate dataVersion field from seed-meta timestamp (#2865)
* feat(resilience): populate dataVersion field from seed-meta timestamp

Sets dataVersion to the ISO date of the most recent static bundle
seed, making the data vintage visible to API consumers.

* fix(resilience): bump score cache to v7 for dataVersion field addition
2026-04-09 12:22:46 +04:00
Elie Habib
6d9e7d6f6b feat(notifications): gate all endpoints behind PRO entitlement (#2852)
* feat(notifications): gate all notification endpoints behind PRO entitlement

Notifications were accessible to any signed-in user. Now all notification
API endpoints require tier >= 1 (Pro plan) with proper upgrade messaging
and checkout flow integration.

Backend: api/notification-channels.ts, api/notify.ts, api/slack/oauth/start.ts,
api/discord/oauth/start.ts all check getEntitlements() and return 403 with
upgradeUrl for free users.

Frontend: preferences-content.ts shows upgrade CTA with Dodo checkout overlay
instead of notification settings for non-Pro users.

* fix(notifications): use hasTier(1) and handle null entitlement state

Address Greptile review comments:
1. Replace isEntitled() with hasTier(1) to match backend tier check exactly
2. When entitlement state is null (not loaded yet), show full notification
   panel instead of upgrade CTA (backend enforces anyway)
2026-04-09 09:18:19 +04:00
Elie Habib
09ed68db09 fix(resilience): revert overall score to domain-weighted average + fix RSF direction (#2847)
* fix(resilience): revert overall score to domain-weighted average + fix RSF direction

1. overallScore reverted from baseline*(1-stressFactor) to
   sum(domainScore * domainWeight) — the multiplicative formula
   crushed all scores by 30-50%
2. RSF press freedom: normalizeHigherBetter → normalizeLowerBetter
   (RSF 0=best, 100=worst; Norway 6.52 was scoring 7 instead of 93)
3. Seed script ranking write removed (handler owns greyedOut split)
4. Widget Impact row removed (stressFactor no longer drives headline)
5. Cache keys bumped: score v6, ranking v6, history v3

* fix(resilience): update validation scripts to v6 + remove lock from read-only seed

1. Validation scripts (backtest, correlation, sensitivity) updated from
   v5 to v6 cache keys. Sensitivity formula updated to domain-weighted.
2. Seed script lock removed — read-only health check needs no lock.

* chore: add clarifying comment on orphaned ranking TTL export
2026-04-09 08:49:54 +04:00
Elie Habib
0a64b308a7 fix(health): rename misleading predictions/insights health entries (#2835)
Renamed health check entries to match what they actually monitor:
- predictions -> predictionMarkets (Polymarket/Metaculus prediction
  markets seeder, NOT the AI forecast output)
- insights -> newsInsights (AI news insights seeder, NOT the forecast
  pipeline insights)

The actual forecast output is already monitored as 'forecasts' (OK,
14 records). The old names caused confusion when predictionMarkets
showed EMPTY_DATA, making it look like the forecast pipeline was broken.
2026-04-08 21:33:27 +04:00
Elie Habib
3c10106630 feat(energy): energy key bootstrap registration + health ops (V5-7) (#2831)
* feat(energy): register energy keys in bootstrap + health ops (V5-7)

* fix(energy): remove premature bootstrap keys (no hydration consumers yet)
2026-04-08 19:42:27 +04:00
Elie Habib
f53c05599a feat(resilience): baseline vs stress scoring engine (#2821)
* feat(resilience): baseline vs stress scoring engine

Splits the resilience index into structural capacity (baselineScore)
and active disruption (stressScore) using the dimension type tags from
RESILIENCE_DIMENSION_TYPES (baseline/stress/mixed).

overallScore = baselineScore * (1 - stressFactor) where stressFactor
is clamped to [0, 0.5]. Mixed dimensions contribute to both scores.

Proto fields 10-12 added (baseline_score, stress_score, stress_factor).
Widget updated to display baseline/stress breakdown.
Cache keys bumped v4 -> v5 for atomic rollout.

* fix(resilience): bump history key to v2 for baseline/stress formula change

The overallScore formula changed from domain-weighted-sum to
baselineScore * (1 - stressFactor). Old history entries are
incomparable, causing fake change30d drops of -20 to -30 points.
Versioned history key starts a clean series.
2026-04-08 13:11:31 +04:00
Elie Habib
b8924eb90f feat(energy): Ember monthly electricity seed (V5-6a) (#2815)
* feat(energy): Ember monthly electricity seed — V5-6a

New seed-ember-electricity.mjs writes energy:ember:v1:<ISO2> and
energy:ember:v1:_all from Ember Climate's monthly generation CSV (CC BY 4.0).
Daily cron at 08:00 UTC, TTL 72h (3x interval), >=60 country coverage guard.

Registers in api/health.js, api/seed-health.js, cache-keys.ts, and
ais-relay.cjs. Dockerfile.relay COPY added.

🤖 Generated with Claude Sonnet 4.6 via Claude Code (https://claude.ai/code) + Compound Engineering v2.49.0

Co-Authored-By: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>

* fix(energy): add _country-resolver.mjs to Dockerfile.relay; correct Ember intervalMin (V5-6a)

Two bugs in the Ember seed PR:
1. Dockerfile.relay was missing COPY for _country-resolver.mjs, which
   seed-ember-electricity.mjs imports. Would have crashed with
   ERR_MODULE_NOT_FOUND on first run in production.
2. api/seed-health.js had intervalMin:720 (12h) for a daily (24h) cron.
   With stale threshold = intervalMin*2, this gave only 24h grace --
   the seed would flap stale during the CSV download window.
   Corrected to intervalMin:1440 so stale threshold = 48h (2x interval).

* fix(energy): wire energy:ember:v1:_all into bootstrap hydration (V5-6a)

Greptile P1: api/bootstrap.js was missing the emberElectricity slow-key
entry, violating the AGENTS.md requirement that new data sources be
registered for bootstrap hydration.

energy:ember:v1:_all is a ~60-country bulk map (monthly cadence) -
added to SLOW_KEYS consistent with faoFoodPriceIndex and other
monthly-release bulk keys.

Also updates server/_shared/cache-keys.ts BOOTSTRAP_CACHE_KEYS and
BOOTSTRAP_TIERS to keep the bootstrap test coverage green (bootstrap
test validates that SLOW_KEYS and BOOTSTRAP_TIERS are in sync).

* fix(energy): 3 review fixes for Ember seed (V5-6a)

1. Ember URL: updated to correct current download URL (old path
   returned HTTP 404, seeder could never run).
2. Count-drop guard after failure: failure path now preserves the
   previous recordCount in seed-meta instead of writing 0, so the
   75% drop guard stays active after a failed run.
3. api/seed-health.js: status:error now marks seed as stale/error
   immediately instead of only checking age; prevents /api/seed-health
   showing ok for 48h while the seeder is failing.

* fix(energy): correct Ember CSV column names + fix skipped-path meta (V5-6a)

1. CSV schema: parser was using country_code/series/unit/value/date
   but the real Ember CSV headers are "ISO 3 code"/"Variable"/"Unit"/
   "Value"/"Date". Added COLS constants and updated all row field
   accesses. The schema sentinel (hasFossil check) was always firing
   because r.series was always undefined, causing every seeder run to
   abort. Updated test fixtures to use real column names.
2. Skipped-path meta: lock.skipped branch now reads existing meta and
   preserves recordCount and status while refreshing fetchedAt.
   Previously writing recordCount:0 disabled the count-drop guard after
   any skipped run and made health endpoints see false-ok with zero count.

* fix(energy): remove skipped-path meta write + revert premature bootstrap (V5-6a)

1. lock.skipped: removed seed-meta write from the skipped path. The
   running instance writes correct meta on completion; refreshing
   fetchedAt on skip masked relay/lock failures from health endpoints.
2. Bootstrap: removed emberElectricity from BOOTSTRAP_CACHE_KEYS and
   BOOTSTRAP_TIERS — no consumer exists in src/ yet. Per energyv5.md,
   bootstrap registration is deferred to PR7 when consumers land.

* fix(energy): split ember pipeline writes; fix health.js recordCount lookup

- api/health.js: add recordCount fallback in both seed-meta count reads so
  the Ember domain shows correct record count instead of always 1
- scripts/seed-ember-electricity.mjs: split single pipeline into Phase A
  (per-country + _all data) and Phase B (seed-meta only after Phase A
  succeeds) to prevent preservePreviousSnapshot reading a partial _all key

* fix(energy): split ember pipeline writes; align SEED_ERROR in health.js; add tests

* fix(energy): atomic rollback on partial pipeline failure; seedError priority in health cascade

* fix(energy): DEL obsolete per-country keys on publish, rollback, and restore

* fix(energy): MULTI/EXEC atomic pipeline; null recordCount on read-miss; dataWritten guard

---------

Co-authored-by: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>
2026-04-08 12:25:54 +04:00
Elie Habib
20c65a4f4f feat(email): add deliverability guards to reduce waitlist bounces (#2819)
* feat(email): add deliverability guards to reduce waitlist bounces

Analyzed 1,276 bounced waitlist emails and found typos (gamil.com),
disposable domains (passmail, guerrillamail), offensive submissions,
and non-existent domains account for the majority.

Four layers of protection:
- Frontend: mailcheck.js typo suggestions on email blur
- API: MX record check via Cloudflare DoH, disposable domain
  blocklist, offensive pattern filter, typo-TLD blocklist
- Webhook: api/resend-webhook.js captures bounce/complaint events,
  stores in Convex emailSuppressions table, checked before sending
- Tooling: import script for bulk-loading existing bounced emails

* fix(email): address review - auth, retry, CSV parsing

1. Security: Convert suppress/bulkSuppress/remove to internalMutation.
   Webhook now runs as Convex httpAction (matching Dodo pattern) with
   direct access to internal mutations. Bulk import uses relay shared
   secret. Only isEmailSuppressed remains public (read-only query).

2. Retry: Convex httpAction returns 500 on any mutation failure so
   Resend retries the webhook instead of silently losing bounce events.

3. CSV: Replace naive comma-split with RFC 4180 parser that handles
   quoted fields. Import script now calls Convex HTTP action
   authenticated via RELAY_SHARED_SECRET instead of public mutation.

* fix(email): make isEmailSuppressed internal, check inside mutation

Move suppression check into registerInterest:register mutation
(same transaction, no extra round-trip). Remove public query
entirely so no suppression data is exposed to browser clients.

* test(email): add coverage for validation, CSV parser, and suppressions

- 19 tests for validateEmail: disposable domains, offensive patterns,
  typo TLDs, MX fail-open, case insensitivity, privacy relay allowance
- 7 tests for parseCsvLine: RFC 4180 quoting, escaped quotes, empty
  fields, Resend CSV format with angle brackets and commas
- 11 Convex tests for emailSuppressions: suppress idempotency, case
  normalization, bulk dedup, remove, and registerInterest integration
  (emailSuppressed flag in mutation return)
2026-04-08 11:21:40 +04:00
Elie Habib
83cecb5aef feat(resilience): add WB mean applied tariff rate to tradeSanctions (#2811)
* feat(resilience): add WB mean applied tariff rate to tradeSanctions

World Bank TM.TAX.MRCH.WM.AR.ZS covers 180+ countries, supplementing
the WTO top-50 metrics that only cover major reporters. Reduces
reporter-set bias by providing a global trade openness signal.

Reweights: sanctions 0.45, WTO restrictions 0.15, WTO barriers 0.15,
WB tariff rate 0.25.

* fix: update pinned test assertions for WB tariff rate reweighting

Adjusts scoreTradeSanctions test assertions for the new 4-metric blend
(sanctions 0.45, restrictions 0.15, barriers 0.15, tariff 0.25) and
bumps TOTAL_DATASET_SLOTS from 9 to 10 in payload assembly tests.

* fix(seed): bump static source version to v5 + sync indicator registry for trade

Version bump ensures appliedTariffRate backfills to existing 2026
snapshots. Registry updated from 3-metric to 4-metric trade-sanctions
weights.

* fix(resilience): correct appliedTariffRate sourceKey to resilience:static:{ISO2}

* fix(resilience): bump score cache to v4 + add tariff rate to release-gate fixtures

Score/ranking cache keys bumped to v4 to invalidate stale pre-tariff
cached responses. Release-gate fixtures now include appliedTariffRate
so the gate exercises the full 4-metric trade-sanctions path.

* fix(test): update pinned scorer assertions after rebase onto main

With all Phase 2+3 PRs merged (FX reserves, broadband, WHO metrics,
zero-event guards), the combined fixture data produces economic=66.33,
infrastructure=79, overallScore=68.72.
2026-04-08 10:56:12 +04:00
Elie Habib
6aa822e9f9 feat(resilience): FX reserves adequacy in currencyExternal (#2812)
* feat(resilience): add FX reserves adequacy to currencyExternal dimension

World Bank FI.RES.TOTL.MO (total reserves in months of imports) covers
~160 countries, filling the BIS EER coverage gap (~40 economies).

For BIS countries: reserves supplement volatility + deviation (weight 0.15).
For non-BIS countries: reserves combine with IMF inflation proxy (0.4/0.6
blend) for much better currency stability coverage than inflation alone.

Normalization: 1 month (near crisis) = 0, 12+ months (very safe) = 100.

* fix(seed): bump static source version to v4 for fxReservesMonths backfill

Without version bump, existing 2026 snapshots won't be republished and
fxReservesMonths field will never backfill until next annual cycle.

* fix(resilience): bump score cache to v3 for FX reserves scorer change

scoreCurrencyExternal now includes FX reserves adequacy, changing scores
for all countries. Bump cache key to invalidate stale pre-reserves
cached responses on deploy.

* fix(seed): retry static seed when previous run had failed datasets

shouldSkipSeedYear() now returns false when seed-meta records non-empty
failedDatasets, allowing backfill of datasets that failed on the first
run (e.g., fxReservesMonths upstream outage during v4 rollout).
Previously, partial success with status:'ok' caused all future same-year
runs to skip permanently.
2026-04-08 10:45:25 +04:00
Elie Habib
998c554a6f feat(payments): subscription welcome email + admin notification (#2809)
* feat(payments): subscription welcome email + admin notification

On subscription.active webhook:
1. Send branded welcome email to user via Resend (matches WM design)
2. Send admin notification to elie@worldmonitor.app with plan, email, userId

Also removed the Dodo customer block from checkout creation since
Dodo locks prefilled customer fields as read-only, preventing users
from editing their email/name during payment.

* fix(payments): correct email feature cards per plan tier + fix plan name mapping

Pro plans showed "Full API Access" which is false (apiAccess: false in catalog).
Now shows plan-appropriate features: Pro gets dashboards/alerts, API plans get
API access. Also aligned PLAN_DISPLAY keys with actual catalog planKeys
(api_starter, api_starter_annual, api_business, enterprise).

* fix(payments): address Greptile review on subscription emails

P1: Throw on Resend failure so Convex retries transient errors (5xx,
429, network) instead of silently dropping emails.

P2: Only send welcome email for brand-new subscriptions, not
reactivations. Uses the existing `existing` variable to distinguish.

P2: Log a warning when customer email is missing from the webhook
payload so dropped emails are visible in logs.

* fix(emails): replace placeholder logo and remove Someone.ceo branding

All 3 email templates (subscription welcome, register-interest, daily
digest) used a Unicode circle character as a placeholder logo and
"by Someone.ceo" as a subtitle. Replaced with the actual hosted
WorldMonitor favicon and removed the Someone.ceo line.
2026-04-08 08:05:32 +04:00
Elie Habib
d96259048d feat(energy): canonical energy spine — V5-1 (#2798)
* feat(energy): canonical energy spine seeder + handler read-through (V5-1)

- Add scripts/seed-energy-spine.mjs: daily seeder that assembles one
  energy:spine:v1:<ISO2> key per country from 6 domain keys (OWID mix,
  JODI oil, JODI gas, IEA stocks, ENTSO-E electricity, GIE gas storage)
- TTL 172800s (48h), count-drop guard at 80%, schema sentinel for OWID mix,
  lock pattern + Redis pipeline batch write mirroring seed-owid-energy-mix.mjs
- Update get-country-energy-profile.ts to read from spine first; fall back
  to existing 6-key Promise.allSettled join on spine miss
- Update chat-analyst-context.ts buildProductSupply/buildGasFlows/buildOilStocksCover
  to prefer spine key; fall through to direct domain key reads on miss
- Update get-country-intel-brief.ts to read energy mix from spine.mix + sources.mixYear
  before falling back to energy:mix:v1: direct key
- Add ENERGY_SPINE_KEY_PREFIX and ENERGY_SPINE_COUNTRIES_KEY to cache-keys.ts
- Add energySpineCountries to api/health.js STANDALONE_KEYS and SEED_META
- Add Railway cron comment (0 6 * * *) in ais-relay.cjs
- Add tests/energy-spine-seed.test.mjs: 26 tests covering spine build logic,
  IEA anomaly guard, JODI oil fallback, schema sentinel, count-drop math

* fix(energy): add cache-keys module replacement in redis-caching test

The new ENERGY_SPINE_KEY_PREFIX import in get-country-intel-brief.ts
was not patched in the importPatchedTsModule call used by redis-caching
tests. Add cache-keys to the replacement map to resolve the module.

* fix(energy): add missing fields to spine entry and read-through

- buildOilFields: add product importsKbd (gasoline/diesel/jet/lpg) and belowObligation
- buildMixFields: add windShare, solarShare, hydroShare
- buildGasStorageFields: new helper storing fillPct, fillPctChange1d, trend
- buildSpineEntry: add gasStorage section using new helper
- EnergySpine interface: extend oil/mix/gasStorage to match seeder output
- buildResponseFromSpine: read all new fields instead of hard-coding 0/false

* fix(energy): exclude electricity/gas-storage from spine; add seed-health entry

Spine-first path was returning stale gas-storage and electricity data for
up to 8h after seeding (spine runs 06:00 UTC, gas storage updates 10:30 UTC,
electricity updates 14:00 UTC).

Fix: handler now reads gas-storage and electricity directly in parallel with
the spine read (3-key allSettled). Fallback path drops from 6 to 4 keys since
gas-storage and electricity are already fetched in the hot path.

Also registers energy:spine in api/seed-health.js (daily cron, maxStaleMin
inferred as 2×intervalMin = 2880 min).

Seeder (seed-energy-spine.mjs) and its tests updated to reflect the narrowed
spine schema — electricity and gasStorage fields removed from buildSpineEntry.

* fix(energy): address Greptile P2 review findings

- chat-analyst-context: return undefined when gas imports are 0 rather
  than falling back to totalDemandTj with an "imports" label — avoids
  mislabeling domestic gas demand as imports for net exporters (RU, QA, etc.)
- seed-energy-spine: add status:'ok' to success-path seed-meta write so
  all seed-meta records have a consistent status field regardless of path
2026-04-07 23:40:25 +04:00
Elie Habib
47af642d24 feat(energy): live chokepoint flow calibration from PortWatch DWT — V5-2 (#2797)
* feat(energy): chokepoint flow calibration seeder — V5-2 (Phase 4 PR A)

- Add CHOKEPOINT_FLOWS_KEY to server/_shared/cache-keys.ts
- Add energy:chokepoint-flows:v1 to health.js monitoring (maxStaleMin: 720)
- Add 6h chokepoint flow seed loop to ais-relay.cjs (seed-chokepoint-flows.mjs)
- Fix seeder to use degraded mode instead of throwing when PortWatch absent
- Add degraded-mode and ID-mapping tests to chokepoint-flows-seed.test.mjs

* fix(energy): restore throw for PortWatch absent + register seed-health

- seed-chokepoint-flows: revert degraded-path from warn+return{} back to
  throw; PortWatch absent is an upstream-not-ready error, not a data-quality
  issue — must throw so startChokepointFlowsSeedLoop schedules 20-min retry
- api/seed-health.js: add energy:chokepoint-flows to SEED_DOMAINS so
  /api/seed-health surfaces missing/stale signal (intervalMin: 360 = 6h cron)
- tests: update degraded-mode assertions to match restored throw behavior
2026-04-07 22:51:16 +04:00
Elie Habib
aa794e1369 feat(portwatch): seed per-country port activity (Endpoints 3+4) (#2786)
* feat(portwatch): seed per-country port activity (Endpoints 3+4)

* fix(portwatch): register portwatch-ports seed-meta in api/seed-health.js

* fix(portwatch): correct Endpoint 3 field names and move Redis writes out of fetchAll()

* fix(portwatch): add Endpoint 4 pagination loop and fix anomalySignal divisor symmetry

* fix(portwatch): stable pagination order + add portwatchPortActivity to PENDING_CONSUMERS

* fix(portwatch): degradation guard + hoist prevCountryKeys for correct catch-block TTL extension
2026-04-07 18:33:48 +04:00
Elie Habib
cf27ffbfde feat(portwatch): seed chokepoints reference (Endpoint 2) (#2785)
* feat(portwatch): seed chokepoints reference data from Endpoint 2

* test(bootstrap): exempt portwatchChokepointsRef from consumer check (UI consumer in future PR)

* fix(portwatch): register chokepoints-ref seed-meta in api/seed-health.js

* fix(portwatch): add returnGeometry=false to reduce ArcGIS response size

* fix(portwatch): raise validateFn threshold to 27 (guards against partial ArcGIS responses)

* fix(portwatch): validateFn requires exactly 28 chokepoints (reject partial ArcGIS responses)
2026-04-07 18:24:30 +04:00
Elie Habib
d64172e67e fix(resilience): calibrate scoring anchors — gov revenue, GPI, inflation cap (#2769)
* fix(resilience): calibrate scoring anchors — gov revenue, GPI, inflation cap

Three evidence-based anchor fixes resolving score compression and fragile-state
inflation (Haiti 56→expected ≤35, Somalia ~50→near-0 on next seed run):

1. Replace debt/GDP with gov revenue/GDP in scoreMacroFiscal (weight=0.5)
   Debt/GDP is gamed by HIPC relief: Somalia (5% debt post-cancellation) and Haiti
   (15%) scored near-100 on fiscal resilience despite being credit-excluded. Gov
   revenue as % GDP (IMF GGR_NGDP, anchor 5%=0 → 45%=100) directly measures fiscal
   capacity. seed-imf-macro.mjs now fetches GGR_NGDP alongside PCPIPCH + BCA_NGDPD.

2. Fix GPI anchor in scoreSocialCohesion: worst 4.0 → 3.6
   Empirical GPI range is 1.1–3.4 (2024). Yemen (3.4) was scoring 20/100 instead of
   near-0. With anchor 3.6, Yemen scores 8, Somalia scores 19, Haiti scores 30.

3. Tighten inflation proxy cap in scoreCurrencyExternal: 100% → 50%
   50%+ annual inflation is already catastrophic instability. Tighter cap better
   differentiates fragile states: Haiti 39% drops from score 61 → 22.

Research basis: INFORM Risk Index, FSI methodology, ND-GAIN, World Bank silent debt
crisis documentation, OECD States of Fragility 2022 normalization guidelines.

* fix(seed): use GGR_G01_GDP_PT for gov revenue — GGR_NGDP returns empty payload

GGR_NGDP is a WEO indicator not exposed in the DataMapper values API;
GGR_G01_GDP_PT (Fiscal Monitor) returns 212 countries and covers the
same concept: general government revenue as % of GDP.

* fix(resilience): bump imf-macro key to v2 — atomise govRevenuePct rollout

v1 was seeded 35-day TTL by PR #2766 without govRevenuePct; the seeder
would skip the stale-check and never write the new field until expiry.
v2 forces a clean seed on first Railway deploy, ensuring the scorer's
0.5-weight fiscal-capacity leg is always backed by real data.

* fix(health): bump imfMacro key to v2 in health.js

Missed in the key-bump commit; health endpoint was still checking the
old v1 key and would report imfMacro as permanently stale after v2 is
seeded.
2026-04-06 20:26:02 +04:00
Elie Habib
492a99eccd feat(resilience): IMF macro phase 2 — current account + inflation proxy (#2766)
* feat(resilience): IMF macro phase 2 — current account + inflation proxy

Replace BIS credit (40-country curated list) with IMF WEO current account
balance (~185 countries) in scoreMacroFiscal, and add IMF CPI inflation as
tier-2 fallback for non-BIS countries in scoreCurrencyExternal. New seeder:
scripts/seed-imf-macro.mjs (PCPIPCH + BCA_NGDPD, key: economic:imf:macro:v1,
TTL: 35 days). api/health.js registers imfMacro as STANDALONE + ON_DEMAND.

* fix(resilience): BIS outage uses IMF proxy; imfMacro not on-demand

Two reviewer issues addressed:

1. scoreCurrencyExternal was short-circuiting to {score:50, coverage:0}
   on BIS outage (bisExchangeRaw==null) before checking IMF inflation.
   Now tries IMF proxy first in both cases (BIS absent from curated list
   and BIS seed outage), with coverage=0.35 for outage vs 0.45 for
   curated-list-absent (primary source unavailable reduces confidence).
   Adds pinning test for BIS null + IMF present → coverage=0.35 path.

2. imfMacro was misclassified as ON_DEMAND in health.js even though it
   has a dedicated seeder and seed-meta entry. Removed from ON_DEMAND_KEYS
   so a broken monthly cron surfaces as a seeded-data failure, not a
   silent EMPTY_ON_DEMAND warning.

* fix(resilience): dynamic WEO year + 2x stale window for imfMacro

- seed-imf-macro.mjs: replace hard-coded 2024 periods/sourceVersion
  with weoYears() computed at runtime (currentYear, currentYear-1,
  currentYear-2) so the monthly cron always fetches the latest WEO
  vintage without code changes (e.g. 2025,2024,2023 once April WEO
  publishes)

- api/health.js: imfMacro.maxStaleMin 50400→100800 (1× interval →
  2× interval = 70 days). Matches repo pattern for monthly seeds
  (faoFoodPriceIndex uses 86400 = 2× its 30-day interval). Prevents
  false STALE_SEED flaps from normal schedule slip or one missed run.

* fix(resilience): use loadSharedConfig for iso2-to-iso3 in seed-imf-macro

Direct readFileSync(../shared/...) fails in Railway where rootDirectory=scripts
isolates the build context. loadSharedConfig() from _seed-utils.mjs tries
../shared/ (local dev) then ./shared/ (Railway) — same pattern as all other
seeders that need shared data files.

* fix(bootstrap): register imfMacro in BOOTSTRAP_CACHE_KEYS and SLOW_KEYS

economic:imf:macro:v1 was seeded and used by scoreMacroFiscal /
scoreCurrencyExternal but absent from bootstrap hydration, causing
cold SPA loads to silently degrade to debt-only scoring for ~130
non-BIS countries. Add to SLOW_KEYS consistent with bisExchange/bisCredit.

* fix(cache-keys): add imfMacro to BOOTSTRAP_CACHE_KEYS and BOOTSTRAP_TIERS

Required by bootstrap.test.mjs: tier sets in bootstrap.js must match
BOOTSTRAP_TIERS in cache-keys.ts. imfMacro is slow-tier (monthly seed,
large payload) consistent with bisExchange/bisCredit.

* test(bootstrap): add imfMacro to PENDING_CONSUMERS

imfMacro is currently backend-only (resilience scorer reads it from Redis
directly). Frontend consumer is not yet wired in src/. Add to
PENDING_CONSUMERS to pass bootstrap consumer coverage check.

* test(resilience): update score assertions after sanctions:country-counts rebase

Rebasing on main picked up PR #2763 (sanctions:country-counts:v1 replacing
sanctions:pressure:v1). Combined with IMF macro fixtures, the economic
domain average shifts from 70 → 63.67 and overall from 68.81 → 67.41.
2026-04-06 16:39:48 +04:00
Elie Habib
e0dc630ed5 feat(energy): days of cover global view (Phase 4 PR B) (#2767)
* feat(energy): days of cover analysis key + EnergyComplexPanel oil stocks section

- seed-iea-oil-stocks.mjs exports buildOilStocksAnalysis and writes
  energy:oil-stocks-analysis:v1 via afterPublish hook after main index
- Rankings sorted by daysOfCover desc (net-exporters last), vsObligation,
  obligationMet, regional summaries (Europe/Asia-Pacific/North America)
- EnergyComplexPanel.setOilStocksAnalysis() renders IEA member table with
  below-obligation badges, rank, days vs 90d obligation, regional summary rows
- Health monitoring: seed-meta:energy:oil-stocks-analysis (42d maxStaleMin)
- Gateway cache tier: static (monthly seed data)
- 13 new tests covering sorting, exclusions, regional rollups, obligation logic

* feat(energy): add proto + regenerate service for oil stocks analysis RPC

- Add get_oil_stocks_analysis.proto with OilStocksAnalysisMember,
  OilStocksRegionalSummary sub-messages, and GetOilStocksAnalysisResponse
- Use proto3 optional fields for nullable int32 (daysOfCover, vsObligation,
  avgDays, minDays) avoiding google.protobuf.wrappers complexity
- Regenerate service_client.ts + service_server.ts via make generate
- Update handler fallback and panel null-safety guards for optional fields
- Regenerated OpenAPI docs include getOilStocksAnalysis endpoint

* fix(energy): preserve oil-stocks-analysis TTL via extraKeys; fix seed-meta TTL to exceed health threshold

- Move ANALYSIS_KEY into ANALYSIS_EXTRA_KEY in extraKeys so runSeed() extends
  its TTL on fetch failure or validation skip (was only written in afterPublish,
  leaving the key unprotected on the sad path)
- afterPublish now writes only the seed-meta for ANALYSIS_KEY with a 50-day TTL
  (Math.max(86400*50, TTL_SECONDS)) — exceeds the health maxStaleMin threshold
- Add optional metaTtlSeconds param to writeExtraKeyWithMeta() (backward-compat,
  defaults to existing 7-day value for all other callers)
- Update health.js oilStocksAnalysis maxStaleMin from 42d to 50d to stay below
  the new seed-meta TTL and avoid false stale/missing reports

* fix(energy): preserve seed-meta:oil-stocks-analysis TTL via extraKeys on seeder failure
2026-04-06 16:28:04 +04:00
Elie Habib
cdd8e6edda feat(energy): LNG vulnerability index (Phase 4 PR D) (#2765)
* feat(energy): LNG vulnerability index from JODI gas seeder

Extends seed-jodi-gas.mjs to compute and write energy:lng-vulnerability:v1
alongside per-country keys in afterPublish, ranking top-20 LNG-dependent
and top-20 pipeline-dependent countries. Adds health monitoring and tests.

* fix(energy): preserve LNG vulnerability index TTL via extraKeys on seeder failure
2026-04-06 16:27:34 +04:00
Elie Habib
3e9556c37f feat(resilience): Phase 1 §3.2+§3.3 — full-country sanctions counts + grey-out ranking (#2763)
* feat(resilience): Phase 1 §3.2+§3.3 — full-country sanctions counts + grey-out ranking

§3.2 — Switch sanctions from top-12 to full country counts
- RESILIENCE_SANCTIONS_KEY: 'sanctions:pressure:v1' → 'sanctions:country-counts:v1'
- New key is a plain ISO2→entryCount map covering ALL countries (no top-12 truncation)
- Replaces compound pressure formula with normalizeSanctionCount() piecewise scale:
  0=100, 1-10=90-75, 11-50=75-50, 51-200=50-25, 201+=25→0
- IMPUTE.ofacSanctions removed (country-counts covers all countries; no absent-country
  imputation needed for sanctions)

§3.3 — Grey-out criteria for ranking
- Proto: ResilienceRankingItem.overall_coverage (field 5) + GetResilienceRankingResponse.greyed_out
- GREY_OUT_COVERAGE_THRESHOLD = 0.40: countries below this are excluded from ranking
  but still appear on choropleth in "insufficient data" style
- buildRankingItem() now computes overallCoverage from domain/dimension data
- getResilienceRanking() splits items into ranked (≥0.40) + greyedOut (<0.40)

Tests updated for new sanctions format; overall score anchor updated (67.56).

* fix(resilience): fix ranking cache guard for all-greyed-out + stale shape cases

Two cache bugs:

1. Empty-items guard: `cached?.items?.length` fails when every country falls
   below GREY_OUT_COVERAGE_THRESHOLD (items=[], greyedOut=[…]). The cache was
   written correctly but never served, causing unnecessary rewarming on every
   request for sparse-data deployments.
   Fix: `cached != null && (items.length > 0 || greyedOut.length > 0)`

2. Stale-shape test: agent-written cache test stored a payload without
   `greyedOut` or `overallCoverage`, locking in pre-PR shape. Updated to the
   correct post-deploy shape so the test reflects actual cached content.

Cache key was already bumped to resilience:ranking:v2 (forces fresh compute
on first post-deploy request, avoiding old-shape responses in production).

* fix(resilience): consume greyedOut on choropleth; version ranking cache key

- Add 'insufficient_data' level to ResilienceChoroplethLevel and RESILIENCE_CHOROPLETH_COLORS
- Extend buildResilienceChoroplethMap to accept optional greyedOut array
- Thread greyedOut through DeckGLMap.setResilienceRanking, MapContainer.setResilienceRanking (with replay), and data-loader.ts
- Add 'Insufficient data' tooltip guard for greyed-out countries in DeckGLMap
- Bump RESILIENCE_RANKING_CACHE_KEY to resilience:ranking:v2 to invalidate stale schema-mismatched cache entries
- Update api/health.js probe key to match

* fix(resilience): include greyedOut in seed-meta count to avoid false health alert

seed-meta:resilience:ranking was written with count=response.items.length,
which excludes greyedOut countries. In an all-greyed-out deployment, count=0
causes api/health.js to report the ranking as EMPTY_DATA/critical even though
the cached payload is valid (items:[], greyedOut:[…]).

Fix: count = items.length + greyedOut.length — total scoured countries
regardless of ranking eligibility.

* test(resilience): pin all-greyed-out cache-hit regression

Adds the missing test case: cached payload with items=[] and greyedOut=[…]
must be served from cache without triggering score rewarming.

Previously, `cached?.items?.length` was falsy for this shape, making the
guard ineffective. The fix (items.length > 0 || greyedOut.length > 0) was
correct but unpinned — this test locks it in.
2026-04-06 14:19:16 +04:00
Elie Habib
c6064f3d4b feat(energy): write energy:mix:v1:_all bulk key from OWID seeder (#2746)
* feat(energy): write energy:mix:v1:_all bulk key from OWID seeder

Add buildAllCountriesMap() that produces a compact ISO2-keyed map
(omits iso2/country/seededAt, saves ~30% payload size). Write it as
energy:mix:v1:_all with the same 35-day TTL. Regression gate rejects
if fewer than 150 entries. Health monitor picks it up via energyMixAll
in STANDALONE_KEYS. Tests verify shape, field exclusions, and count parity.

* fix(energy): preserve _all key on seeder failure and add SEED_META freshness check
2026-04-05 23:40:58 +04:00
Elie Habib
106ad5fbe1 fix(health): extend ecbFxRates maxStaleMin for holiday weekends (#2745)
ECB doesn't publish FX on weekends or holidays. Easter 2026 created
a Wed→Mon gap (5 days). Extended from 48h to 96h (5760min) to cover
the longest observed gap without false STALE_SEED alerts.
2026-04-05 23:22:06 +04:00