Commit Graph

351 Commits

Author SHA1 Message Date
Elie Habib
b9f39bb8f3 fix(relay): route USNI through residential proxy to bypass Cloudflare 403 (#1909)
Railway datacenter IPs are blocked by USNI's Cloudflare. Reuses the
existing ytFetchViaProxy/parseProxyUrl pattern with OREF_PROXY_AUTH.
Falls back to direct fetch when proxy is not configured (dev/local).

Replaces curl+proxy approach which failed because curl is not available
in Railway's Node.js container (ENOENT).
2026-03-20 10:33:18 +04:00
Elie Habib
95874c0582 fix(relay): load COMMODITY_SYMBOLS from shared/commodities.json (#1897)
* fix(relay): load COMMODITY_SYMBOLS from shared/commodities.json

The relay had a hardcoded 6-symbol list (VIX, Gold, Oil, NatGas, Silver,
Copper) that overwrote market:commodities-bootstrap:v1 every ~40 minutes,
wiping Platinum, Palladium, Aluminum, Brent, Gasoline, Heating Oil,
Uranium, and Lithium seeded by seed-commodity-quotes.mjs.

Use requireShared('commodities.json') — same source as the seed script
and the frontend — so adding a commodity to the JSON file is the only
change needed. YAHOO_ONLY is auto-derived: futures (=F) and indices (^).

* ci: trigger typecheck

* fix(relay): restore ^GSPC/^DJI/^IXIC in YAHOO_ONLY and add URA/LIT

Deriving YAHOO_ONLY from COMMODITY_SYMBOLS alone dropped the three major
indices (^GSPC, ^DJI, ^IXIC) which live in MARKET_SYMBOLS, not COMMODITY_SYMBOLS.
With Finnhub API key present, seedMarketQuotes() would route them through
Finnhub instead of Yahoo, potentially caching wrong index values.

Explicit list: indices hardcoded + futures/^ from COMMODITY_SYMBOLS + ETFs.
2026-03-20 10:24:55 +04:00
Elie Habib
d2903c832e fix(relay): replace proxy+curl with native fetch for USNI seed (#1908)
* fix(relay): replace proxy+curl with native fetch for USNI seed

USNI WordPress API is a public endpoint (no auth required). The previous
implementation required RESIDENTIAL_PROXY_AUTH or OREF_PROXY_AUTH to be
set, silently skipping the seed when neither was available in Railway.

* fix(ci): remove paths-ignore from typecheck workflow

When a PR only touches scripts/, the typecheck workflow was skipped
entirely. GitHub posts no status, so the required 'typecheck' check
is never satisfied and merge is blocked.

paths-ignore is unsafe on required status checks.
2026-03-20 10:15:43 +04:00
Elie Habib
46cd3728d6 fix(forecast): tighten reportable effect quality (#1902)
* fix(forecast): tighten reportable effect quality

* fix(forecast): preserve structural political carryover

* chore(forecast): document effect grouping heuristics
2026-03-20 00:44:21 +04:00
Elie Habib
8768d10b7f fix(forecast): tighten interaction semantics (#1896)
* fix(forecast): tighten interaction semantics

* fix(forecast): narrow maritime family inference

* fix(forecast): keep full reportable interaction graph
2026-03-19 23:34:46 +04:00
Elie Habib
3d365ffad8 fix(relay): extend Redis TTL from 2x to 6x seed interval to survive relay downtime (#1898)
* fix(cable-health): extend Redis TTL from 1h to 24h to survive relay downtime

CACHE_TTL was 3600s (1h). Relay pings every 30min. Two consecutive missed
pings expired the key, leaving health.js reading null and reporting EMPTY.
cachedFetchJson is cache-aside, not stale-while-revalidate: once the key
expires from Redis there is no stale fallback at the health.js layer.
24h TTL keeps the key alive through multi-hour relay outages; the 30min
warm-ping still keeps data fresh in normal operation.

* fix(relay): extend 2x-interval TTLs to 6x to survive relay downtime

Keys with TTL=2x their seed interval expire after just one missed ping.
Same root cause as cable-health (CACHE_TTL=3600 with 30min ping).

Changes:
- CHOKEPOINT_TRANSIT_TTL: 1200s → 3600s (10min interval)
- TRANSIT_SUMMARY_TTL:    1200s → 3600s (10min interval)
- WEATHER_CACHE_TTL:      1800s → 5400s (15min interval)
- AVIATION_SEED_TTL:      3600s → 10800s (30min interval, intl delays)
- NOTAM_SEED_TTL:         3600s → 10800s (30min interval)

All now at 6x their seed interval, matching the gold standard pattern.

* test: update TRANSIT_SUMMARY_TTL assertion to require 6x interval minimum
2026-03-19 23:34:35 +04:00
Elie Habib
e434769e37 feat(forecast): add simulation action ledger (#1891)
* feat(forecast): add simulation action ledger

* fix(forecast): preserve directional interaction effects
2026-03-19 21:01:47 +04:00
Elie Habib
486f5f799f fix(forecast): tighten family effect credibility (#1880)
* fix(forecast): tighten family effect credibility

* fix(forecast): respect domain effect thresholds
2026-03-19 18:24:40 +04:00
Elie Habib
08cc2723cc fix(forecast): wire per-situation simulation into per-forecast worldState (#1879)
buildForecastTraceArtifacts was building worldState after tracedPredictions,
so simulation data was never available to buildForecastTraceRecord. Each
forecast's caseFile.worldState had situationId/familyId/simulationSummary
all undefined, making the 3-round MiroFish simulation invisible at the
forecast level.

Fix:
- Compute worldState before tracing (so simulationState is ready)
- Build forecastId → situationSimulation lookup from worldState.simulationState
- Pass lookup into buildForecastTraceRecord; inject situationId, familyId,
  familyLabel, simulationSummary, simulationPosture, simulationPostureScore
  into caseFile.worldState for each matched forecast
- Add regression assertion to forecast-trace-export tests

All 194 forecast tests pass.
2026-03-19 17:19:49 +04:00
Jon Torrez
f4183f99c7 feat: self-hosted Docker stack (#1521)
* feat: self-hosted Docker stack with nginx, Redis REST proxy, and seeders

Multi-stage Docker build: esbuild TS handler compilation, vite frontend
build, nginx + Node.js API under supervisord. Upstash-compatible Redis
REST proxy with command allowlist for security. AIS relay WebSocket
sidecar. Seeder wrapper script with auto-sourced env vars from
docker-compose.override.yml. Self-hosting guide with architecture
diagram, API key setup, and troubleshooting.

Security: Redis proxy command allowlist (blocks FLUSHALL/CONFIG/EVAL),
nginx security headers (X-Content-Type-Options, X-Frame-Options,
Referrer-Policy), non-root container user.

* feat(docker): add Docker secrets support for API keys

Entrypoint reads /run/secrets/* files and exports as env vars at
startup. Secrets take priority over environment block values and
stay out of docker inspect / process metadata.

Both methods (env vars and secrets) work simultaneously.

* fix(docker): point supervisord at templated nginx config

The entrypoint runs envsubst on nginx.conf.template and writes
the result to /tmp/nginx.conf (with LOCAL_API_PORT substituted
and listening on port 8080 for non-root). But supervisord was
still launching nginx with /etc/nginx/nginx.conf — the default
Alpine config that listens on port 80, which fails with
"Permission denied" under the non-root appuser.

* fix(docker): remove KEYS from Redis allowlist, fix nginx header inheritance, add LLM vars to seeders

- Remove KEYS from redis-rest-proxy allowlist (O(N) blocking, Redis DoS risk)
- Move security headers into each nginx location block to prevent add_header
  inheritance suppression
- Add LLM_API_URL / LLM_API_KEY / LLM_MODEL to run-seeders.sh grep filter
  so LLM API keys set in docker-compose.override.yml are forwarded to seed scripts

* fix(docker): add path-based POST to Redis proxy, expand allowlist, add missing seeder secrets

- Add POST /{command}/{args...} handler to redis-rest-proxy so Upstash-style
  path POSTs work (setCachedJson uses POST /set/<key>/<value>/EX/<ttl>)
- Expand allowlist: HLEN, LTRIM (seed-military-bases, seed-forecasts),
  ZREVRANGE (premium-stock-store), ZRANDMEMBER (seed-military-bases)
- Add ACLED_EMAIL, ACLED_PASSWORD, OPENROUTER_API_KEY, OLLAMA_API_URL,
  OLLAMA_MODEL to run-seeders.sh so override keys reach host-run seeders

---------

Co-authored-by: Elie Habib <elie.habib@gmail.com>
2026-03-19 12:07:20 +04:00
Elie Habib
2deccac691 fix(forecast): allocate publish output by family (#1868)
* fix(forecast): allocate publish output by family

* fix(forecast): backfill deferred family selections
2026-03-19 11:42:12 +04:00
Elie Habib
ee0f124b3f feat(forecast): add family spillover engine (#1866)
* feat(forecast): add family spillover engine

* fix(forecast): require direct spillover links

* fix(forecast): stabilize family spillover wiring
2026-03-19 10:52:06 +04:00
Elie Habib
0dae526a4b feat(markets): add NSE/BSE India market support (#1863)
* feat(config): add NSE and BSE (India) market support (#1102)

* fix(india-markets): wire NSE/BSE symbols into stocks.json so seed fetches them

- Add 20 India symbols (^NSEI, ^BSESN, 18x .NS equities) to shared/stocks.json
- Mark all .NS symbols + indices as yahooOnly (Finnhub does not support NSE)
- Remove orphan src/config/india-markets.ts; stocks.json is the seed source of truth

* fix(india-markets): sync scripts/shared/stocks.json mirror

* fix(ci): exclude scripts/data/ and scripts/node_modules/ from unicode safety scan

---------

Co-authored-by: lspassos1 <lspassos@icloud.com>
2026-03-19 10:31:37 +04:00
Elie Habib
67e6cceac2 ci: skip typecheck + fix Vercel deploy for scripts-only PRs (#1857)
* ci: skip typecheck for scripts-only PRs; fix vercel-ignore empty SHA

Typecheck workflow:
- Add paths-ignore for scripts/** and .github/** on pull_request and push.
  Seed scripts are plain .mjs — not TypeScript — so typechecking adds ~2min
  with zero coverage benefit for scripts-only changes.

vercel-ignore.sh:
- When VERCEL_GIT_PREVIOUS_SHA is empty or invalid (can happen on incremental
  PR pushes), fall back to git merge-base HEAD origin/main instead of defaulting
  to exit 1 (build). This was causing Vercel to deploy on scripts-only PRs even
  though the ignore script correctly excludes scripts/ from web-relevant paths.

* fix(ci): remove .github/** from typecheck paths-ignore to unblock PR
2026-03-19 09:51:25 +04:00
Elie Habib
0b338afed8 fix(forecast): calibrate simulation posture scoring (#1860)
* fix(forecast): calibrate simulation posture scoring

* fix(forecast): version and rebalance simulation scoring
2026-03-19 09:48:41 +04:00
Elie Habib
15e2a6fccb feat(forecast): drive simulation rounds from actor actions (#1858) 2026-03-19 09:08:04 +04:00
DrDavidL
7fdfea854b security: add unicode safety guard to hooks and CI (#1710)
* security: add unicode safety guard to hooks and CI

* fix(unicode-safety): drop FE0F, PUA; fix col tracking; scan .husky/

- Remove FE0F (emoji presentation selector) from suspicious set — it
  false-positives on ASCII keycap sequences (#️⃣ etc.) in source strings
- Remove Private Use Area (E000–F8FF) check — not a parser attack vector
  and legitimately used by icon font string literals
- Fix column tracking for astral-plane characters (cp > 0xFFFF): increment
  by 2 to match UTF-16 editor column positions
- Remove now-unused prevCp variable
- Add .husky/ to SCAN_ROOTS and '' to INCLUDED_EXTENSIONS so extensionless
  hook scripts (pre-commit, pre-push) are included in full-repo scans

---------

Co-authored-by: Elie Habib <elie.habib@gmail.com>
2026-03-19 08:48:08 +04:00
Elie Habib
b4c7a39cfd fix(gdelt): exponential backoff + post-exhaust cooldown for persistent 429s (#1856)
* fix(gdelt): exponential backoff + post-exhaust cooldown for 429s

Military and cyber queries consistently 429 all retry attempts
because GDELT's rate limit window exceeds the previous 50s max
backoff. Old linear 20/35/50s was too short.

Changes:
- Backoff: 60s, 120s, 240s (exponential) instead of 20/35/50s
- POST_EXHAUST_DELAY: 2min cooldown after a topic exhausts all
  retries before moving to the next topic, giving GDELT's sliding
  window time to reset
- `exhausted: true` flag on retry-exhausted results to trigger cooldown

* fix(gdelt): scope post-exhaust cooldown to 429 exhaustion only

exhausted:true was set for any terminal error (500, timeout, bad JSON),
causing the 2min rate-limit cooldown to fire for non-rate-limit failures.
Only mark exhausted:true when 429 was the reason the topic gave up.
2026-03-19 08:41:35 +04:00
Elie Habib
dd56ab052c fix(market): prevent stale-data overwrite and add Yahoo retry for commodities/macro panels (#1854)
- seed-economy: skip Redis write for macro-signals when totalCount=0 so
  Yahoo failures don't overwrite previously cached good data
- seed-economy: raise MACRO_TTL 900→1800s so valid data survives two
  missed 15-min cron cycles
- health: tighten macroSignals maxStaleMin 60→20 to alert before TTL
  expires (was 4x longer than the data lifetime)
- ais-relay: add one retry pass for commodity symbols that return null,
  with 3s cooldown, to recover from transient Yahoo 429 rate limits
2026-03-19 08:38:13 +04:00
Elie Habib
b52916b7e3 fix(health): adjust gdeltIntel maxStaleMin for 6h cron; warn on expired-key EXPIRE no-op (#1853)
* fix(health): adjust gdeltIntel maxStaleMin for 6h cron; fix silent EXPIRE no-op on expired keys

- gdeltIntel maxStaleMin: 150 → 420 (6h cron + 1h grace). The 150 threshold was
  calibrated for the old 2h cron — with 6h intervals it fires STALE throughout
  most of each cycle, masking the signal entirely.

- _seed-utils extendExistingTtl: EXPIRE returns 0 (no-op) on expired/missing keys,
  but the log always said "Extended TTL on N key(s)" regardless. Added per-result
  checking: keys that returned 0 now emit a WARNING so the death-spiral condition
  (validate fails + key expired + EXPIRE is silently a no-op) is visible in logs
  rather than silently passing as if TTL was extended.

* fix(seed-health): align gdelt-intel intervalMin to 210 (420min maxStaleMin / 2)

Codex flagged mismatch: health.js allows 420min before flagging gdelt-intel
stale, but seed-health.js still used intervalMin: 150 (flags after 300min).
Ops tooling monitoring seed-health would generate spurious alerts for most
of each 6h cron cycle. Align to 210min per the maxStaleMin/2 convention.
2026-03-19 08:33:14 +04:00
Elie Habib
214b17d757 fix(forecast): align published and candidate state surfaces (#1852)
* fix(forecast): align published and candidate state surfaces

* fix(forecast): preserve projected published situations
2026-03-19 08:24:26 +04:00
Elie Habib
568408b0ca fix(forecast): tighten simulation effect links (#1851) 2026-03-19 03:55:19 +04:00
Elie Habib
bc4f80ad90 fix(gdelt-intel): extend TTL to 24h, restore 6 topics + client tabs, fix health thresholds (#1846)
* fix(gdelt-intel): extend CACHE_TTL to 24h, restore 6 topics, fix health thresholds

- CACHE_TTL: 21600 (6h) → 86400 (24h) so verifySeedKey merge fallback always has a prior snapshot
  when GDELT 429s all topics across multiple cron cycles
- Restore 6 topics (sanctions + intelligence were dropped; 4-topic validate>=4 created a brittle all-or-nothing gate)
- validate threshold: >=4 → >=3 (at least 3 of 6 topics must return articles)
- health.js gdeltIntel maxStaleMin: 300 → 150 (2h cron + 30min grace; matches actual cron interval)
- health.js cableHealth maxStaleMin: 60 → 10080 (on-demand handler writes seed-meta with 7-day TTL)

* fix(gdelt-intel): expose sanctions/intelligence tabs to client; fix health thresholds

- Add sanctions and intelligence to client INTEL_TOPICS so the new seed
  buckets are actually requested and rendered in GdeltIntelPanel
- Quote "intelligence agency" in seed query (matches project convention
  for multi-word GDELT phrases; prevents false positive token splits)
- cableHealth maxStaleMin: 10080 → 90 (ais-relay warm-ping runs every 30min;
  90min = 3× interval; 10080 would hide relay failures for 7 days)
2026-03-19 03:51:04 +04:00
Elie Habib
70a06041ff fix(widgets): reduce design drift between AI widgets and dashboard panels (#1849)
* fix(widgets): reduce design drift between AI widgets and default panels

- System prompt: add explicit visual design rules with anti-patterns
  (no font-family overrides, CSS vars only, max 4px radius, compact rows)
  Restore 6 intel topics (sanctions + intelligence), fix disp-stat → disp-stat-box class name
  Add correct HTML pattern examples for rows, tables, stats grids
- widget-sanitizer.ts PRO iframe: monospace font stack, CSS variable palette,
  font-family:inherit!important on *, table/th/td baseline styles, change-positive/negative classes
- main.css: enforce font-family:inherit!important on .wm-widget-generated and all descendants
  so inline style="font-family:..." from AI output cannot override the monospace look

* fix(widgets): correct table wrapper structure, ban PRO style blocks

- trade-tariffs-table is a wrapper div around <table>, not a class on
  the table itself; fix example and add explicit anti-pattern note
- PRO widget prompt: disallow <style> blocks in body content since
  they load after the iframe head CSS and can override the monospace
  font and dark palette guardrails (source-order wins)
2026-03-19 03:48:42 +04:00
Fayez Bast
cf1fdefe92 feat: effective tariff rate source (#1790)
* feat: effective tariff rate source

* fix(trade): extract parse helpers, fix tests, add health monitoring

- Extract htmlToPlainText/toIsoDate/parseBudgetLabEffectiveTariffHtml
  to scripts/_trade-parse-utils.mjs so tests can import directly
- Fix toIsoDate to use month-name lookup instead of fragile
  new Date(\`\${text} UTC\`) which is not spec-guaranteed
- Replace new Function() test reconstruction with direct ESM import
- Add test fixtures for parser patterns 2 and 3 (previously untested)
- Add tariffTrendsUs to health.js STANDALONE_KEYS + SEED_META
  (key trade:tariffs:v1:840:all:10, maxStaleMin 900 = 2.5x the 6h TTL)

* fix(test): update sourceVersion assertion for budgetlab addition

---------

Co-authored-by: Elie Habib <elie.habib@gmail.com>
2026-03-19 03:45:32 +04:00
Elie Habib
10958a397b feat(forecast): synthesize reports from simulation state (#1850) 2026-03-19 03:40:22 +04:00
Elie Habib
a40c0a11fb feat(forecast): add simulation state transitions (#1847) 2026-03-19 03:25:52 +04:00
Elie Habib
a3492e8b4e fix(forecast): refine world-state situation clustering (#1843) 2026-03-19 02:55:22 +04:00
Stable Genius
84ce00d026 fix: add India boundary override from Natural Earth 50m data (#1796)
Adds India (IN) to country-boundary-overrides.geojson using the same
Natural Earth 50m Admin 0 Countries dataset already used for Pakistan.
The override system automatically replaces the base geometry on app load,
providing a higher-resolution MultiPolygon boundary (1518 points).

Renames the fetch script to reflect its broader purpose and updates
doc references in CONTRIBUTING.md and maps-and-geocoding.mdx.

Fixes koala73/worldmonitor#1721

Co-authored-by: stablegenius49 <185121704+stablegenius49@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Elie Habib <elie.habib@gmail.com>
2026-03-19 02:40:05 +04:00
Elie Habib
439b8f1c7d fix(gdelt): relax validate threshold 4→2 to prevent recurring STALE_SEED (#1842)
* fix(gdelt): relax validate threshold from 4 to 2 topics to prevent STALE on partial 429s

When GDELT rate-limits any single topic, fetchWithRetry returns { articles: [] }.
With validate requiring all 4 topics populated, a single 429 causes atomicPublish
to skip the write entirely — seed-meta TTL is NOT extended. After 2-3 consecutive
cron runs that fail validation, seedAge exceeds maxStaleMin:300 and health reports
STALE_SEED.

Fix: require only 2/4 topics to have articles. A partial snapshot is far better
than no write at all — it keeps seed-meta fresh and prevents recurring STALE alerts.

Also fix stale comment claiming cron runs every 4h (actual cron is 2h).

* fix(gdelt): merge previous snapshot for 429-failed topics instead of overwriting with empty

The earlier validate relaxation (>= 2) still overwrote previously cached articles
for rate-limited topics with empty arrays, causing blank tabs and empty RPC responses
for up to the 6h TTL even though good data was available.

Fix: after fetching all topics, read the existing canonical key from Redis. For any
topic that returned 0 articles due to 429 exhaustion, restore the previous snapshot's
articles for that topic before publishing. The validate >= 2 threshold remains as a
safety net for first-run or total GDELT outages.
2026-03-19 02:29:44 +04:00
Elie Habib
c5a0701381 fix(forecast): cap dominant situation output (#1837)
* fix(forecast): cap dominant situation output

* fix(forecast): count actual capped situations
2026-03-19 02:23:08 +04:00
Elie Habib
73ecd794e7 feat(aviation): add Wingbits fallback for civilian flights when OpenSky unreachable (#1839)
* feat(aviation): add Wingbits as civilian flight fallback when OpenSky is unreachable

OpenSky is completely unreachable from Railway (direct fetch blocked,
residential proxy returning CONNECT 422). Wingbits is healthy with
2000+ flights per cycle.

Adds /wingbits/track relay endpoint that accepts a viewport bbox,
queries Wingbits /v1/flights, and returns all flights (no military
filter) as PositionSample objects. Wires it as third source in
trackAircraft after OpenSky relay and direct OpenSky attempts fail.

OpenSky remains first in the waterfall so it auto-recovers when the
proxy is fixed.

* fix(aviation): scale Wingbits bbox width by cos(lat) and read all onGround variants

P1: longitude degree is only 60 nm at the equator; multiply by cos(centerLat)
to avoid over-expanding the bbox at high latitudes.

P2: honour f.og ?? f.gr ?? f.onGround to match all Wingbits field variants
(mirrors seed-military-flights.mjs:871).
2026-03-19 02:20:27 +04:00
Elie Habib
47e942011b fix(forecast): improve situation-aware publish quality (#1840) 2026-03-19 02:11:27 +04:00
Elie Habib
c331f9a214 fix(seo): split IndexNow submissions by host (IndexNow requires one host per request) (#1838) 2026-03-19 01:57:27 +04:00
Elie Habib
39a479629b fix(r2): add s3 client to scripts runtime (#1831)
* fix(r2): add s3 client to scripts runtime

* fix(r2): update scripts lockfile for s3 client
2026-03-19 00:51:54 +04:00
Elie Habib
65194a1c58 fix(seo): add IndexNow key, sitemap lastmod dates for crawl recovery (#1833)
- Add IndexNow verification key file (public/a7f3e9d1b2c44e8f9a0b1c2d3e4f5a6b.txt)
- Update sitemap.xml lastmod to 2026-03-19 to signal freshness to crawlers
- Add lastmod dates to blog sitemap via @astrojs/sitemap serialize()
- Add scripts/seo-indexnow-submit.mjs to resubmit all 23 URLs to IndexNow
  (run after deploy: node scripts/seo-indexnow-submit.mjs)
2026-03-19 00:48:11 +04:00
Elie Habib
d5ae068051 fix(r2): retry transient trace storage failures (#1832) 2026-03-19 00:24:47 +04:00
Elie Habib
dd3487be46 chore(forecast): log llm path segments (#1827) 2026-03-18 23:14:22 +04:00
Elie Habib
541aa5720e chore(forecast): log llm stage skips (#1819) 2026-03-18 22:46:42 +04:00
Elie Habib
11c444fcc9 fix(gdelt): reduce topics 6→4 to cut 429 rate-limit pressure (#1817)
* fix(gdelt): reduce topics 6→4 to cut 429 rate-limit pressure

Drops sanctions and intelligence topics (covered by other data sources).
Keeps military, cyber, nuclear, maritime as core high-signal topics.

Happy-path runtime drops from ~2.5min to ~1.5min. Worst-case retry
storm is now 3 gaps instead of 5, significantly reducing total backoff
duration per run on the 2h cron cycle.

Lowers validation threshold from ≥3 to ≥2 of 4 topics.

* fix(gdelt): reduce cron to 4h and extend TTL to 6h

Data from GDELT's 24h window doesn't turn over fast enough to justify
2h polling. Switching to 4h halves Railway runs (12/day → 6/day) and
doubles the cooldown between IP hits, reducing 429 pressure.

TTL bumped 4h→6h so cached data outlives the 4h cron gap.
health.js maxStaleMin 200→300 (5h, warning window before 6h expiry).
seed-health.js intervalMin 100→150 (150×2=300 = maxStaleMin).

Railway cron schedule needs updating to: 0 */4 * * *

* fix(gdelt): sync UI topic list with seed and require all 4 topics for write

P1: Remove sanctions and intelligence from INTEL_TOPICS in
src/services/gdelt-intel.ts so the panel no longer renders tabs that
can never be hydrated from the 4-topic Redis payload. Tabs now match
the seed: military, cyber, nuclear, maritime.

P2: Raise validation threshold back to >=4 (all topics required).
With >=2 a partial run would overwrite the complete snapshot with
incomplete data, making missing tabs show blank panels until the next
full run. Requiring all 4 means a partial run extends the existing
TTL instead of replacing good data with bad.
2026-03-18 20:25:34 +04:00
Elie Habib
21da606e74 fix(health): align seed TTLs and maxStaleMin thresholds to prevent false STALE alerts (#1814)
Audit revealed 6 mismatches where data TTLs or maxStaleMin thresholds were
too tight relative to cron intervals, causing spurious STALE_SEED warnings.

gdeltIntel: maxStaleMin 180 to 240 (1h cron now has 4x buffer vs 3x)
securityAdvisories: TTL 70m to 120m, maxStaleMin 90 to 120 (2x cron buffer)
techEvents: TTL 360m to 480m, maxStaleMin 420 to 480 (1h buffer for 6h cron)
forecasts: TTL 80m to 105m (outlives maxStaleMin:90 when cron is delayed)
correlationCards: TTL 10m to 20m (data outlives maxStaleMin:15)
usniFleet: maxStaleMin 420 to 480 (extra buffer for relay-seeded key)
2026-03-18 19:53:57 +04:00
Elie Habib
7b26a9046a fix(forecast): keep forecast execution in one runtime (#1813) 2026-03-18 19:46:12 +04:00
Elie Habib
2b228da916 fix(forecast): dedupe situation-overlap forecasts (#1807)
* fix(forecast): dedupe situation-overlap forecasts

* fix(forecast): reuse situation clusters in publish flow

* fix(forecast): reuse publish trace context
2026-03-18 17:57:03 +04:00
Elie Habib
1d0f75cbf6 fix(health): fix cableHealth and spending EMPTY/CRIT flapping (#1806)
* fix(health): prevent cableHealth EMPTY/CRIT when NGA has no active warnings

When the NGA broadcast-warn API returns 0 active warnings, computeHealthMap
produces an empty cables object. The handler wrote recordCount:0 to seed-meta,
causing health.js to report EMPTY (CRIT) even though the feed is operational.

Zero cable disruptions is a valid healthy state, not missing data.
Write Math.max(count, 1) so health.js only fires CRIT if the feed is
genuinely broken (no seed-meta at all), not when NGA reports no disruptions.

* fix(health): fix cableHealth EMPTY/CRIT — TTL shorter than warm-ping interval

Root cause: CACHE_TTL was 600s (10 min) but the relay warm-ping runs every
30 min. The cable-health-v1 Redis key expired 20 min before the next ping,
causing health.js to see a missing key → EMPTY → CRIT for 20 of every 30 min.

Fix: increase CACHE_TTL to 3600s (1h) to match health.js maxStaleMin:60 and
outlive the warm-ping interval with margin.

Also reverts the earlier incorrect Math.max(count,1) change — health.js reads
the cached payload directly, not meta.recordCount.

* fix(health): fix spending EMPTY/CRIT — TTL matched seed interval exactly

SPENDING_CACHE_TTL was 3600s = SPENDING_SEED_INTERVAL_MS (1h). At exact
equality the key expires the moment the next seed runs, causing a window
where health.js sees EMPTY → CRIT. Double the TTL to 2h so the key always
outlives the seeder.

* fix(health): fix weather CACHE_TTL matching seed interval exactly

WEATHER_CACHE_TTL was 900s = WEATHER_SEED_INTERVAL_MS (15 min). Same
TTL=interval race as spending: key can expire at the exact moment the
seeder fires, leaving a window where health.js sees EMPTY. Double TTL
to 1800s (30 min) to eliminate the race.

* fix(health): fix 5 remaining TTL < 2x interval races in ais-relay

Ensure every Redis key TTL is at least 2× its seeder interval so a single
slow/missed cycle never causes EMPTY/CRIT flapping:

- USNI:              7h → 12h  (interval 6h,  was 1.17x)
- THEATER_POSTURE:  15m → 20m  (interval 10m, was 1.5x)
- CYBER:             3h →  4h  (interval 2h,  was 1.5x)
- CHOKEPOINT_TRANSIT: 15m → 20m (interval 10m, was 1.5x)
- TRANSIT_SUMMARY:  15m → 20m  (interval 10m, was 1.5x)

* test: update transit-summaries TTL assertion to match new 2x minimum rule
2026-03-18 16:43:34 +04:00
Elie Habib
e58a608262 fix(forecast): make trace writing single-writer (#1801)
* fix(forecast): make trace writing single-writer

* fix(forecast): preserve chained refresh requests
2026-03-18 11:00:43 +04:00
Elie Habib
9b953a4699 fix(posture): compute vessel counts server-side from AIS stream (#1787)
* fix(posture): compute vessel counts server-side from AIS stream

trackedVessels was hardcoded to 0 in seedTheaterPosture(). The relay
has live AIS data in the vessels Map but never used it for posture.

Now counts military vessels (shipType 35/50-59, naval prefixes, MMSI
patterns) within each theater's bounds using the same identification
logic as isLikelyMilitaryCandidate(). Vessels seen in the last 6h
contribute to both the vessel count and the combined posture level.

This ensures the bootstrap theater posture data shows accurate vessel
presence regardless of whether the client has AIS toggled on.

* perf(posture): use candidateReports instead of iterating all vessels

candidateReports is already pre-filtered for military candidates on
AIS message arrival. No need to re-apply isLikelyMilitaryCandidate
on every vessel for every theater. Reduces ~90k function calls to a
simple bounds check on the candidate set.

* fix(posture): address vessel count issues in theater posture

1. Remove early exit on flights.length === 0 so vessel-only scenarios
   still seed posture (P1 — Codex review comment)
2. Add isStrictMilitaryVessel() to filter candidateReports to shipType
   35/55 and named naval vessels only — drops tugs, pilot boats, SAR
   craft (shipType 50-59) that inflate counts in maritime theaters
3. Cap vessel contribution at floor(elevated_threshold / 2) to prevent
   naval traffic from dominating flight-calibrated posture thresholds
4. Update seed-meta recordCount and log to include vessel counts
2026-03-18 10:51:36 +04:00
Elie Habib
527002f873 fix(forecast): improve trace enrichment diagnostics (#1797)
* fix(forecast): avoid duplicate prior world-state read

* feat(forecast): record llm enrichment failure reasons

* fix(forecast): preserve latest pointer continuity fallback
2026-03-18 09:55:28 +04:00
Elie Habib
c024380dde fix(health): increase gdeltIntel maxStaleMin 120→180 to stop STALE flapping (#1792)
* fix(health): increase gdeltIntel maxStaleMin from 120 to 180

Seeder runs every ~120min but threshold was exactly 120min, causing
STALE/CRIT flapping whenever there's any timing jitter. 180min gives
a 60min buffer to prevent oscillation.

* fix(health): increase gdeltIntel CACHE_TTL to 4h to match maxStaleMin:180

CACHE_TTL was 7200s (120min) while health.js maxStaleMin was raised to
180min. When a seed run is delayed past 120min the data key expires,
health evaluates EMPTY/CRIT before the stale check can ever fire, making
the 60min warning buffer unreachable. Setting TTL to 14400s (240min)
ensures the key outlives the stale threshold so STALE_SEED triggers
before EMPTY/CRIT on delayed runs.
2026-03-18 08:25:25 +04:00
Elie Habib
6b1ea49397 feat(forecast): add report continuity history (#1788)
* feat(forecast): cluster situations in world state

* feat(forecast): add report continuity history

* fix(forecast): stabilize report continuity matching
2026-03-18 00:27:03 +04:00
Elie Habib
c1f8aa516b feat(forecast): add situation clustering to world state (#1785)
* feat(forecast): cluster situations in world state

* fix(forecast): stabilize situation continuity ids
2026-03-17 22:47:58 +04:00