* 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.
* 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.
* 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.
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.
* 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.
* 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
* 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)
* 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.
* 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.
* 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.
* 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
* 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.
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.
* 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>
* 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.
* 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.
* 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
* 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
* 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>
* 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
* 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
* 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)
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.
* 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.
* 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>
* 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)
* 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.
* 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.
* 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.
* 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
* 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
* 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.
* 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.
* 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
* 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
* 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.
* 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
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.