Skip to content

Fetch live cable advisories and repair activity#8

Merged
koala73 merged 2 commits into
mainfrom
codex/add-subsea-cable-fault-advisories-feature
Jan 10, 2026
Merged

Fetch live cable advisories and repair activity#8
koala73 merged 2 commits into
mainfrom
codex/add-subsea-cable-fault-advisories-feature

Conversation

@koala73

@koala73 koala73 commented Jan 10, 2026

Copy link
Copy Markdown
Owner

Motivation

  • Replace hardcoded cable advisories and repair-ship fixtures with live-sourced signals so the map reflects up-to-date subsea cable faults and repair deployments.
  • Surface fault severity and repair deployment information on the cable layer and popups to improve situational awareness around internet stability.
  • Fit new signal types into existing map/popup paradigms so behavior and styling remain consistent with other layers.

Description

  • Added a new RSS-based ingestion service src/services/cable-activity.ts that scrapes configured feeds and produces CableAdvisory and RepairShip records.
  • Wired the service into the app by exporting it from src/services/index.ts and calling fetchCableActivity from src/App.ts during initial load and on a 30-minute interval via a new loadCableActivity helper.
  • Updated src/components/Map.ts to store cableAdvisories/repairShips, render advisory and repair-ship overlays, apply advisory-driven classes to cable paths, and expose setCableActivity plus helper lookups like getCableAdvisory and getCableName.
  • Extended src/components/MapPopup.ts, src/types/index.ts, src/styles/main.css, and src/config/geo.ts to add new types (CableAdvisory, RepairShip), popup views (cable-advisory, repair-ship), marker styles/animations, and remove the previously hardcoded CABLE_ADVISORIES / REPAIR_SHIPS datasets.

Testing

  • Started the dev server with npm run dev -- --host 0.0.0.0 --port 4173 and Vite reported ready (UI served locally).
  • Captured a UI screenshot using a Playwright script which produced artifacts/cable-activity-live.png, confirming advisories/ship markers and popups render; the script completed successfully.
  • The dev server logs showed proxy/network reachability warnings while fetching external RSS feeds, but the local UI still loaded and the screenshot was generated.
  • No automated unit tests were added or executed as part of this change.

Codex Task

@vercel

vercel Bot commented Jan 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
worldmonitor Ready Ready Preview, Comment Jan 10, 2026 2:49am

- Integrated official NGA Maritime Safety Information API
- Real navigation warnings for cable operations worldwide
- Coordinate parsing from warning text (DMS and decimal formats)
- Cableship name extraction (CS, M/V, CABLESHIP patterns)
- Automatic matching to nearest known undersea cable
- Severity detection (fault vs operational warnings)
- Ship status detection (on-station vs enroute)

Data source: https://msi.nga.mil/api/publications/broadcast-warn
@koala73
koala73 merged commit 2a76f1f into main Jan 10, 2026
2 checks passed
@koala73
koala73 deleted the codex/add-subsea-cable-fault-advisories-feature branch January 12, 2026 18:47
@SebastienMelki SebastienMelki added the area: infrastructure Cables, pipelines, power grids, cyber threats label Feb 17, 2026
EleCor79 added a commit to EleCor79/BehavioralHealthPulse that referenced this pull request Mar 2, 2026
Distribute all 32 feeds from feeds-health-italy-eu.csv into the FEEDS
config consumed by loadNews() / fetchCategoryFeeds():

- ministero-salute: +MinSalute News Specifiche (CSV koala73#18)
- iss-epicentro:    +ISS Notizie (CSV koala73#3), +Epicentro Coronavirus (CSV koala73#17)
- aifa-tracker:     +AIFA Feed (CSV koala73#5), +EMA News (CSV koala73#8), +FDA (CSV koala73#12)
- agenas-ospedali:  +AGENAS RSS (CSV koala73#4), +PNRR (CSV koala73#6/koala73#19),
                    +Lombardia (CSV koala73#20), +Lazio (CSV koala73#21)
- ema-europa:       +EMA Clinical Trials (CSV koala73#22)
- ecdc-sorveglianza:+ECDC Weekly Threats (CSV koala73#23), +WHO DON (CSV koala73#9),
                    +ProMED (CSV koala73#10)
- live-news:        +Sanitainformazione (CSV koala73#24), +Humanitas (CSV koala73#26)
- europe:           +EU Core Health Indicators (CSV koala73#31),
                    +GLOBSEC HRI (CSV koala73#32), +ISTAT (CSV koala73#13),
                    +EIN Health Europe (CSV koala73#29)
- rare-diseases:    NEW category — EURORDIS (CSV koala73#14), Orphanet IT (CSV koala73#15),
                    Telethon (CSV koala73#16), CDC FluView (CSV koala73#11)
                    Panel disabled by default, available in settings

Co-Authored-By: Claude Opus 4.6 <[email protected]>
bradleybond512 referenced this pull request in bradleybond512/worldmonitor-macos Mar 2, 2026
Closes task #8. Adds a soft outer glow (hotspots-bloom ScatterplotLayer)
rendered beneath existing pulse rings for high/breaking hotspots.

- Alpha-breathes with pulseTime (1200ms period, slower than pulse ring)
- Radius is 3.5× the hotspot base radius; scales with escalationScore
- Color matches hotspot severity: red for breaking, amber for high
- Zoom-gated at ≥ 2.5 to avoid visual noise at global view
- Skipped entirely when prefers-reduced-motion is set

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
gefa-sc pushed a commit to gefa-sc/worldmonitor that referenced this pull request Mar 2, 2026
…sh-interval

Reduce auto-refresh interval from 5 minutes to 1 hour
koala73 added a commit that referenced this pull request Apr 24, 2026
…#3379)

* chore(tests): add chokepoint-baselines fixture + parity guard (§L #8)

Closes gap #8 from docs/internal/energy-atlas-registry-expansion.md §L —
the last missing V5-7 golden fixture.

Adds `tests/fixtures/chokepoint-baselines-sample.json` as a snapshot of
the expected buildPayload output shape (7 EIA chokepoints, top-level
source/referenceYear/chokepoints). Extends the existing
`tests/chokepoint-baselines-seed.test.mjs` with a 3-test fixture-parity
describe block that catches any silent drift in:

- Top-level key set (source, referenceYear, chokepoints array length)
- Position-by-position chokepoint entries (id, relayId, mbd)
- updatedAt ISO-parseability (format only — value is volatile)

Doesn't snapshot updatedAt byte-for-byte because it's a per-run
timestamp; parity is scoped to the schema-stable fields downstream
consumers depend on (CountryDeepDivePanel transit-chokepoint scoring,
shock RPC CHOKEPOINT_EXPOSURE lookup, chokepoint-flows calibration).

If a future change adds/removes an entry or renames a field, this
suite fails until the fixture is updated alongside — making schema
drift a deliberate reviewed action rather than a silent shift.

Test plan:
- [x] `npx tsx --test tests/chokepoint-baselines-seed.test.mjs` — 17/17 pass
- [x] `npm run typecheck` — clean
- [x] `npm run test:data` — 6697/6697 pass (+3 new fixture-parity cases)

* fix(tests): validate fixture against buildPayload + assert all fields (review P2)

Codex P2: the parity check validated entries against the CHOKEPOINTS
constant, not buildPayload().chokepoints — so it guarded the source
array rather than the seeded wire contract the fixture claims to
snapshot. If buildPayload ever transforms entries (coerce, reorder,
normalize), the check would miss it.

Also P2: the fixture contained richer fields (name, lat, lon) but the
old assert only checked id/relayId/mbd — most of the fixture realism
was unused and produced false confidence.

Fix:
- Parity loop now iterates payload.chokepoints (seeded output, not
  the raw source array) and asserts id, relayId, name, mbd, lat, lon
  per entry.
- Added an entry-key-set assertion that catches added/removed fields
  between seed and fixture — forces deliberate evolution rather than
  silent drift.

18 tests pass (was 17), typecheck clean.
koala73 added a commit that referenced this pull request Apr 25, 2026
…2 — flag-gated dark)

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <[email protected]>
koala73 added a commit that referenced this pull request Apr 25, 2026
…2 — flag-gated dark) (#3407)

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

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

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

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

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

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

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

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

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

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

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

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

* feat(resilience): financialSystemExposure component seeders + methodology doc

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

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

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

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

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

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

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

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

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

* chore(resilience): address self-review hardening (P1 + 4×P2 + 4×P3)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(resilience): scoreFinancialSystemExposure preflights UNVERSIONED seed-meta keys

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

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

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

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

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

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

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

* fix(resilience): address Greptile P1 + P2 catches on PR #3407 (4 issues)

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context, extended thinking) <[email protected]>
koala73 added a commit that referenced this pull request Apr 26, 2026
…1 + HIC=0) (#3427)

* fix(resilience): repair externalDebtCoverage broken-data-source (mrv=1 trap + HIC=0 sentinel)

PR #3425's post-merge cohort dry-run surfaced that `externalDebtCoverage`
(0.25-weight recovery dimension) is awarding `debtToReservesRatio: 0` →
score 100 to **72/164 countries (44%)** — including NO/CH/DK/SE/FI/IS/
KW/AE/SG/LU. A 25%-weighted dimension that scores nearly half the
universe at 100 is not discriminating; it's structurally elevating
wealthy countries on a metric that should differentiate them.

Two-layered root cause:

1. **WB `mrv=1` coverage trap** (memory `feedback_wb_bulk_mrv1_null_coverage_trap`).
   `mrv=1` returns a SINGLE year across all countries with `value: null`
   for late-reporters; the script silently drops them. The mature
   pattern (already used by `seed-wb-external-debt.mjs` for the
   financialSystemExposure dim) is `mrv=5` + per-country
   pickLatest-non-null. This script was written before the memory was
   captured and never migrated.

2. **WB IDS dataset is LMIC-scoped.** `DT.DOD.DSTC.CD` (short-term
   external debt) returns the literal `0` (not `null`) for high-income
   countries that don't report into the IDS series — they're
   out-of-scope, not data-sparse. Under the prior code, `Number(0) → 0`
   passed `Number.isFinite()`, divided by reserves, and yielded
   `debtToReservesRatio: 0` → score 100. The construct semantically
   does not apply to HICs (which manage external debt through different
   channels), so dropping them is correct — they fall to the dim's
   existing IMPUTE fallback (`recoveryExternalDebt`: 50/0.3/'unmonitored').

**Same fix applied to `seed-recovery-reserve-adequacy.mjs`** which had
the same `mrv=1` trap on `FI.RES.TOTL.MO` (no HIC=0 sentinel issue
there because reserve-months is genuinely measured for HICs).

**Propagation:** No cache-prefix bump. Score cache `resilience:score:v15:`
has 12h TTL; the next bulk-warm tick after this seed lands will refresh
all 222 country scores against the new debt seed, so the 72 false-100
countries naturally drop to IMPUTE 50 within 12h. Manual immediate
warm: run `seed-recovery-external-debt.mjs` then `seed-resilience-scores.mjs`.

**Expected v15 ranking impact:** countries previously inflated by
false-perfect externalDebtCoverage will drop. The cohort dry-run
should be re-run post-deploy to capture the empirical signature; PR
#3425's `npm run dryrun:resilience` script does this.

**Audit lineage:** Plan 2026-04-26-001 cohort dry-run (commit
`ae7229e5e` post-merge) → user audit finding #7+#8 → this PR.
Memory `feedback_wb_bulk_mrv1_null_coverage_trap` is the established
recipe; this is the second seeder migrated to it (first was
`seed-wb-external-debt.mjs` in PR #3412).

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

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

* fix(resilience): null-skip before Number() coercion + post-merge force-refresh script

Two reviewer findings on PR #3427's initial commit:

**P1: Number(null) === 0 defeats the latest-non-null picker.**
The mrv=5 + pickLatest fix was structurally correct but `Number(null)`
returns `0` (not `NaN`), passes `Number.isFinite()`, and lets a
`value: null` record overwrite an older non-null record in the
year-comparison branch. Net effect: the very late-reporters mrv=5
was supposed to capture (KW/QA/AE) get a final `value: 0` from a
recent null record, then the country-level `debt.value <= 0` HIC
filter drops them entirely — same end-state as before, fix defeated.

Fix: explicit `if (record?.value == null) continue` BEFORE coercion
in both seeders. Same pattern as the standard recipe documented in
memory `feedback_wb_bulk_mrv1_null_coverage_trap`. Reviewer caught
this within minutes of the initial push.

**P2: Bundle-runner freshness gate would delay propagation by ~24d.**
`scripts/_bundle-runner.mjs:240` skips bundle items where
`elapsed < intervalMs * 0.8`. For the 30d recovery bundle, that means
the new seeder code wouldn't RUN on Railway for ~24 days post-merge
even though the code is in the container. Meanwhile
`seed-resilience-scores` would keep recomputing scores against the
old debt seed → fix appears not to land.

Fix: `scripts/post-pr3427-force-refresh.mjs` — one-shot post-merge
script that invokes both seeders directly (bypassing the bundle
runner's freshness gate). Runs the two seeders in series, then
prints the follow-up bulk-warm command. Documented in the script
header and to be referenced in PR #3427's release checklist.

Both fixes are surgical — same files, no logic re-architecture.

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

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

---------

Co-authored-by: Claude <[email protected]>
koala73 added a commit that referenced this pull request May 10, 2026
* feat(convex): add mcpProTokens table + issue/validate/revoke + http routes (U1)

Non-key Pro MCP identity layer for the Pro-tier MCP access plan. Mirrors
apiKeys.ts shape but stores no key material — the row's _id IS the
bearer identifier (referenced from OAuth code/token records as
mcpTokenId).

- mcpProTokens table: {userId, clientId?, name?, createdAt, lastUsedAt?,
  revokedAt?} indexed by_userId; userApiKeys untouched.
- Internal mutations: issueProMcpToken (tier ≥ 1 gate, 5-row cap with
  silent oldest-revoke rotation, audit-preserving), validateProMcpToken,
  internalRevokeProMcpToken (server-side rollback path for U5 — no Clerk
  context needed), touchProMcpTokenLastUsed (debounced 5min).
- Public mutations: revokeProMcpToken + listProMcpTokens (Clerk-auth).
- HTTP internal routes: /api/internal-{issue,validate,revoke}-pro-mcp-token
  with x-convex-shared-secret guard. Validate route schedules touch via
  ctx.scheduler.runAfter, matching apiKeys pattern (http.ts:839).

28 new tests; full convex suite 245/245.

Plan unit U1: docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md

* feat(server): pro-mcp-token edge helper — issue/validate/revoke (U2)

Edge-runtime-safe wrappers around U1's Convex internal HTTP routes. No
positive cache on validate (revoke takes effect on next request);
negative-cache only at pro-mcp-token-neg:<tokenId> (60s TTL) for
known-bad bearers.

- issueProMcpTokenForUser: typed ProMcpIssueFailed (pro-required /
  invalid-user-id / config / network), 3s timeout.
- validateProMcpToken: neg-cache short-circuit BEFORE Convex hit; fail-soft
  on 5xx/timeout (returns null but does NOT write neg-cache sentinel —
  prevents transient-blip poisoning of legitimate tokens). Convex's
  validate route schedules touchProMcpTokenLastUsed internally; no
  edge-side waitUntil needed.
- revokeProMcpToken: returns result object (no throw) so rollback callers
  don't have their original error masked. Sets neg-cache sentinel after
  successful revoke.
- invalidateProMcpTokenCache: public sentinel writer for U9.

25 tests including a load-bearing integration test for revoke → next
validate short-circuits without Convex round-trip.

Plan unit U2.

* feat(oauth): apex Clerk grant page + signed-grant mint API (U3)

Bridge between the apex Clerk session and the api-subdomain Pro MCP
consent flow. Apex /mcp-grant SPA reads the OAuth nonce + registered
client metadata so users see the real client_name + redirect host
(anti-phishing). On Authorize the page POSTs to mcp-grant-mint, gets
back a fixed redirect URL with a HMAC-signed grant token, and navigates.

- mcp-grant.html + src/mcp-grant-main.ts: Vite multi-page entry (matches
  existing settings.html / live-channels.html convention).
- api/internal/mcp-grant-mint.ts: Clerk-auth POST. Re-checks tier ≥ 1,
  re-validates redirect_uri allowlist (defense-in-depth), HMAC-signs
  {userId, nonce, exp+5min}, SETEX mcp-grant:<nonce>, returns FIXED
  redirect URL (https://api.worldmonitor.app/oauth/authorize-pro).
- api/internal/mcp-grant-context.ts: GET companion for the SPA to fetch
  the real client_name + redirect_host. Same Pro-tier gate as mint.
- api/_mcp-grant-hmac.ts: shared sign/verify helper. Signature is over
  exact post-base64url-decode bytes (key-order/whitespace independent).
  U5 imports verifyGrant from this module.
- vercel.json: /mcp-grant → /mcp-grant.html rewrite + no-store header
  rule. Catch-all SPA fallback adjusted to exclude the new page.
- api/oauth/register.js: export isAllowedRedirectUri so DCR allowlist is
  reused (no parallel impl).
- 35 tests; full deploy-config + edge-functions + mcp + pro-mcp-token
  suites green.

Plan unit U3.

* feat(oauth): consent page Pro-CTA-default + 'Use API key instead' (U4)

R1 of the plan: a logged-in Pro user authorising MCP from claude.ai
never sees the API-key input field by default. The consent page now
leads with a brand-green "Sign in with WorldMonitor Pro" CTA pointing
at the apex /mcp-grant page; the existing API-key form is hidden
behind a "Use API key instead" disclosure for Starter+ holders.

- Default state: form display:none; Pro CTA dominant.
- Error state: existing errorMsg path still works — when ke.textContent
  is non-empty (or XHR-retry returns invalid_key), inline script auto-
  reveals the form so the user doesn't lose context after a bad key.
- Deep-link: /oauth/authorize?...#api-key shows the form on initial
  load, so Starter+ users can bookmark.
- Pro CTA href: https://worldmonitor.app/mcp-grant?nonce=<URL-encoded n>.
  Apex page reads the OAuth nonce server-side (no client_name forwarded
  via URL — already covered by U3's mcp-grant-context).
- nonce shared with U5: the same oauth:nonce:<n> row that U5's
  /oauth/authorize-pro will GETDEL. Handler still mints exactly one
  nonce per GET — no double-issue.
- XSS-escape preserved on client_name + redirect_host + nonce + errorMsg.
- 18 new tests; existing handler logic untouched.

Plan unit U4.

* feat(oauth): /oauth/authorize-pro bounce-back endpoint (U5)

Receives the apex grant bounce-back, validates the HMAC-signed grant,
atomically consumes both Redis nonces (mcp-grant + oauth:nonce), issues
a non-key Pro identity row in Convex via U2, and writes the OAuth code
with {kind:'pro', userId, mcpTokenId, ...} for U6 to read at exchange
time.

Security ordering (HMAC-first to avoid burning nonces on forged tokens):
  1. verifyGrant (U3's _mcp-grant-hmac, no Redis)
  2. URL-nonce vs payload-nonce match
  3. GETDEL mcp-grant:<n>
  4. strict {userId, exp} tuple match vs grant payload (forge defense)
  5. GETDEL oauth:nonce:<n>
  6. GET oauth:client:<client_id> + redirect_uri allowlist re-check
  7. getEntitlements(userId) tier ≥ 1 re-check (lapse defense)
  8. issueProMcpTokenForUser (U2)
  9. SETEX oauth:code:<code> (kind:'pro', 600s)
  10. 302 redirect_uri?code=<code>[&state=...] with Cache-Control:no-store

oauth:code shape (load-bearing for U6):
  {kind:'pro', userId, mcpTokenId, client_id, redirect_uri,
   code_challenge, scope:'mcp_pro'}

Best-effort revoke when SETEX fails after issueProMcpTokenForUser
succeeds — calls U2's no-throw revokeProMcpToken; logs orphan-row id
on revoke failure but never masks the original 500.

35 tests; vercel rewrite + api-route-exceptions registered as
external-protocol.

Plan unit U5.

* feat(oauth): McpAuthContext discriminated union + Pro token shape (U6)

Token endpoint and bearer resolver learn about Pro-shape tokens without
breaking Starter+ legacy paths.

api/_oauth-token.js:
- New resolveBearerToContext(token) returns a discriminated union:
    {kind:'env_key', apiKey}    -- legacy WORLDMONITOR_VALID_KEYS resolution
    {kind:'pro', userId, mcpTokenId}    -- new Pro identity
- resolveApiKeyFromBearer kept as backward-compat wrapper. Returns
  apiKey for env_key kind, null for pro kind (so legacy callers can't
  mis-handle a Pro bearer).

api/oauth/token.js → api/oauth/token.ts:
- Converted to TypeScript so it can import validateProMcpToken from
  server/_shared/pro-mcp-token cleanly. Vercel routes by basename;
  /oauth/token rewrite unaffected.
- Default handler wires injected deps via tokenHandler(req, deps) for
  testability (mirrors U3/U5 pattern).
- authorization_code branch: dispatches on codeData.kind. Pro path
  validates client_id + redirect_uri bind, PKCE-verifies, mints tokens
  via storeProTokens (object shape). Legacy path unchanged.
- refresh_token branch: Pro refreshes call validateProMcpToken
  (per-request Convex hit, no positive cache); revoked row → invalid_grant.
  Cross-user defensive guard: Convex-returned userId must match bearer's.
  family_id, mcpTokenId, scope ('mcp_pro') preserved across rotation.
- client_credentials grant untouched.

oauth:token:<uuid> shapes:
- Legacy (preserved): JSON.stringify("<sha256-hex-64>") or "<fingerprint-16>"
- Pro (new):          JSON.stringify({kind:'pro', userId, mcpTokenId})
- oauth:refresh:<uuid> Pro shape includes client_id, scope, family_id
  for rotation discipline (matches legacy semantics).

26 new tests; sibling suites (oauth-authorize, oauth-authorize-pro, mcp,
pro-mcp-token — 101 tests) regress green. tsc -p tsconfig.api.json clean.

Plan unit U6.

* feat(mcp): Pro identity, atomic INCR-first quota, internal HMAC fetch (U7)

Behavioral heart of the Pro MCP plan. api/mcp.ts now resolves bearers
as McpAuthContext (env_key | pro), runs Pro-specific pre-checks, and
enforces a hard 50/UTC-day cap via INCR-first reservation with
DECR-rollback on cap-exceed and on tool-dispatch failure.

Pre-dispatch chain for Pro context (synchronous, in order):
  1. validateProMcpToken(mcpTokenId)         — null = 401 revoked
  2. defensive userId match                  — 401 cross-user
  3. getEntitlements(userId) tier ≥ 1, mcpAccess: true, validUntil — fail-closed
  4. per-minute slidingWindow(60, 60s) keyed pro-user:<userId>  — fail-open
  5. for tools/call ONLY: pipeline INCR + EXPIRE 172800
       newCount > 50 → DECR rollback + -32029 + 429 + Retry-After
       INCR Redis transient → -32603 + 503 + Retry-After:5 (hard-cap correctness)
  6. dispatch tool; on throw → DECR rollback + -32603

Internal-HMAC tool fetches (replaces X-WorldMonitor-Key for Pro):
  payload   = ${ts}:${METHOD}:${pathname}:${queryHash}:${bodyHash}:${userId}
  queryHash = SHA-256(canonicalQueryString)   sorted keys, URL-encoded
  bodyHash  = SHA-256(bodyBytes)              SHA-256("") for empty
  sig       = HMAC-SHA-256(MCP_INTERNAL_HMAC_SECRET, payload)
  headers   = X-WM-MCP-Internal: ${ts}.${b64url(sig)}, X-WM-MCP-User-Id

Replay defense: payload binds method+path+queryHash+bodyHash, so a
captured signature for /api/news/v1/list-feed-digest cannot be reused
on /api/intelligence/v1/deduct-situation. ±30s window.

server/_shared/mcp-internal-hmac.ts: single source of truth for sign
helpers. U8 verify imports the same canonicalisation primitives.

server/_shared/pro-mcp-token.ts: dailyCounterKey(userId), 50/172800s
constants exported for U9 to read the SAME Redis key the enforcement
writes.

_execute signatures changed (params, base, context) — option A from
the plan. Cache-only tools unchanged.

waitUntil unused: Convex internal-validate-pro-mcp-token schedules
touchProMcpTokenLastUsed itself (verified at convex/http.ts:1035-1040
from U1's commit 4530bdf).

22 new tests + 23 Starter+ regression tests in tests/mcp.test.mjs;
sibling chain (oauth-token, pro-mcp-token, oauth-authorize-pro,
mcp-grant-mint, mcp-proxy) all green. tsc + biome clean.

Plan unit U7.

* feat(gateway): HMAC-verify internal-MCP requests + sanitised propagation (U8)

Verifier counterpart to U7's signer. Gateway accepts X-WM-MCP-Internal
HMAC headers in lieu of an API key for Pro internal-MCP traffic, then
hands a freshly-constructed Request with sanitised trusted markers to
the downstream handler. isCallerPremium learns to honour those markers
so Pro framework/systemAppend semantics survive in summarize-article,
get-country-intel-brief, deduct-situation.

Gateway flow at the top of createDomainGateway's handler:
  1. STRIP inbound x-wm-mcp-internal-verified + x-user-id (anti-injection)
  2. If X-WM-MCP-Internal present:
       verifyInternalMcpRequest re-canonicalises and HMAC-verifies the
       request shape (method+pathname+queryHash+bodyHash+userId), ±30s
       window, timing-safe compare. Failure → 401 invalid_internal_mcp_
       signature (no fall-through to validateApiKey — present-but-bad is
       a deliberate forge attempt).
       getEntitlements(userId): tier ≥ 1 + mcpAccess === true (fail-closed).
       Construct NEW Request via new Request(url, {method, body, headers})
       with x-wm-mcp-internal-verified=<per-process nonce> +
       x-user-id=<verified userId>. Skip validateApiKey + IP rate limit.

isCallerPremium adds a NEW first branch: timing-safe compare of
x-wm-mcp-internal-verified against the per-process nonce + non-empty
x-user-id + defensive getEntitlements re-fetch. Catches direct-edge-
function consumers (api/widget-agent.ts, chat-analyst.ts, me/entitlement.ts,
v2/shipping/webhooks/...) that don't run the gateway strip step.

DEFENSE-IN-DEPTH BEYOND PLAN: trusted marker is a per-process random
16-byte nonce, NOT the literal '1'. Sweep found direct edge functions
calling isCallerPremium without gateway-strip protection — a constant
marker would be spoofable from outside. The nonce is born once at
process startup, only the gateway knows it, comparison is timing-safe.

verifyInternalMcpRequest lives in server/_shared/mcp-internal-hmac.ts
next to U7's sign helpers — single source of truth for canonical form
+ payload + ±30s window. Drift between sign and verify is structurally
impossible.

26 new tests + 100 sibling regression tests (gateway-cdn-origin-policy,
premium-stock-gateway, premium-fetch, pro-mcp-token, mcp). tsc clean.

Plan unit U8.

* feat(catalog): mcpAccess feature flag + env vars + Pro-MCP docs (U10)

Catalog, schema, type, env, and docs scaffolding to make the Pro MCP
flow deployable end-to-end. U9 unblocks on this — settings UI gates on
hasFeature('mcpAccess') (distinct from existing apiAccess paywall).

- convex/config/productCatalog.ts: PlanFeatures.mcpAccess on every tier.
  Free=false. Pro_monthly/Pro_annual=true. API_starter / API_starter_annual
  / API_business=true. Enterprise=true. Pro plan marketing copy mentions
  "MCP access for Claude Desktop & other AI clients (50 calls/day)".
- convex/schema.ts + convex/payments/cacheActions.ts: mcpAccess optional
  field on the entitlements validator; legacy rows pass.
- server/_shared/entitlement-check.ts: CachedEntitlements.features
  mcpAccess?: boolean (consumed by U7 + U8).
- src/services/entitlements.ts: EntitlementState.features.mcpAccess?:
  boolean — unblocks hasFeature('mcpAccess') for U9's settings tab gate.
- .env.example: new "Pro MCP" section with MCP_PRO_GRANT_HMAC_SECRET
  (apex grant bridge, U3/U5) and MCP_INTERNAL_HMAC_SECRET (gateway
  service-auth, U7/U8). Both 32-byte base64; both required.
- docs/mcp-server.mdx: "Pro sign-in flow" section + 50/day quota
  callout + "Connected MCP clients" management pointer.

Bundled fixups for U7/U8 strict-mode TS errors:
- mcp-internal-hmac.ts: bufferToBase64Url uses for-of (avoids
  noUncheckedIndexedAccess error on bytes[i]).
- gateway.ts: drop unused INTERNAL_MCP_USER_ID_HEADER import.

tsc -p tsconfig.api.json + tsc -p tsconfig.json clean.
9 entitlements tests + 71 gateway-internal-mcp + mcp tests pass.

Plan unit U10.

* feat(settings): Connected MCP clients tab + quota endpoint + revoke (U9)

User-facing surface that makes the Pro MCP plan visible. Settings UI
gets a new "Connected MCP clients" tab gated on hasFeature('mcpAccess')
(distinct from the existing apiAccess-gated 'API Keys' tab — Pro users
without apiAccess see only the new tab).

Endpoints:
- GET /api/user/mcp-quota → {used, limit:50, resetsAt}. Reads the
  SAME Redis key (dailyCounterKey from U2) that U7 enforcement writes.
  Single source of truth.
- POST /api/user/mcp-revoke {tokenId} → forwards Clerk-derived userId
  (NOT client-supplied) to Convex internal-revoke-pro-mcp-token, then
  calls invalidateProMcpTokenCache so any in-flight bearer with this
  tokenId 401s within 60s. 404 on NOT_FOUND, 409 on ALREADY_REVOKED.
  Tenancy enforced inside Convex (row.userId === userId).

UI (in src/components/UnifiedSettings.ts, no child component split —
matches existing API Keys tab pattern):
- Tab gated on hasFeature('mcpAccess') so Pro users see it without
  needing apiAccess.
- Quota header auto-refreshes every 30s; interval cleared on tab-
  switch / close / destroy.
- Each row: name, createdAt, lastUsedAt (relative), revokedAt
  (struck-through + badge); active rows show Revoke button with
  confirm() dialog (matches existing pattern).
- Empty state: click-to-copy https://api.worldmonitor.app/mcp.

Frontend service in src/services/mcp-clients.ts: listMcpClients (Convex
query), revokeMcpClient (POST /api/user/mcp-revoke), fetchMcpQuota.

11 + 14 = 25 new tests; api-route-exceptions registered as
internal-helper.

Plan unit U9.

---

PLAN COMPLETE — 10/10 units shipped. Follow-up polish noted in U9
report: CSS rules for .mcp-clients-* classes (suggest copy from
.api-keys-* rules); empty-state URL is hardcoded to
api.worldmonitor.app/mcp.

* style(settings): mcp-clients CSS rules mirroring api-keys (U9 polish)

* fix(pro-mcp): apply Tier-2 code-review residuals — security + correctness

Review pass after U10 (3 reviewers: code-reviewer, ce-security-reviewer,
ce-adversarial-reviewer). Findings BLOCKING + HIGH + MEDIUM + LOW
applied per user direction.

BLOCKING (gateway):
- Add validUntil check to gateway's HMAC-verify entitlement re-check.
  Defense-in-depth against Convex-fallback paths returning a stale row
  past its expiry.

HIGH — adv-001: cross-user OAuth nonce hijack:
- mcp-grant-mint now claims oauth nonces atomically via Upstash SET NX
  semantics. If the nonce is already claimed by a DIFFERENT userId,
  return 403 NONCE_CLAIMED_BY_OTHER_USER. Same userId multi-tab retry
  is idempotent: re-sign the grant using the existing claim's exp so
  authorize-pro's strict tuple equality still matches.
- mcp-grant-context performs the same cross-user check so the apex SPA
  refuses to render consent context for a hijacked nonce.

MEDIUM — adv-008: refresh-token loss during Convex transient:
- validateProMcpToken returns a discriminated union {ok:'valid',userId}
  | {ok:'revoked'} | {ok:'transient'}. Negative-cache writes only on
  'revoked'. validateProMcpTokenOrNull wrapper preserves backward
  compat for callers that don't need the distinction (per-request MCP
  edge stays fail-closed).
- token.ts refresh path: on 'transient', best-effort restore the
  consumed refresh token to Redis with the original TTL and return
  503 server_error + Retry-After:5. Client retries; if Convex recovers,
  refresh succeeds. No more permanent session loss on a Convex blip.

MEDIUM — adv-002: counter overshoot lockout:
- mcp.ts: on cap-exceeded after DECR rollback, if newCount is still
  far above PRO_DAILY_QUOTA_LIMIT (overshoot from prior transient),
  pipelined INCR+DECR probe + bounded DECR-sweep to converge the
  counter back near the limit. Prevents one Redis hiccup from locking
  out a paying Pro user for the rest of the UTC day.

MEDIUM — code-reviewer #4: 5-row cap race in Convex:
- mcpProTokens.issueProMcpToken now revokes ALL active rows beyond
  MAX-1 (sorted by createdAt) on each issue, so concurrent inserts
  that briefly produce 6 active rows converge back to 5 on the next
  call.

LOW:
- adv-003: executeTool throws cache_all_null when every cache read
  returns null AND there were keys configured — triggers DECR rollback
  in dispatchToolsCall instead of silently burning quota on a degenerate
  empty result.
- code-reviewer #10: gateway strips X-WM-MCP-Internal + X-WM-MCP-User-Id
  from the trusted Request before forwarding to handler (no info leak).
- adv-004: 256 KB body cap (Content-Length + post-buffer count) on
  both gateway strip and HMAC-verify paths — bounds memory amplification.
- adv-006: dailyCounterKey now env-prefixes (preview deploys can't
  collide with production traffic on the same Upstash instance).
  Reader (mcp-quota.ts) and writer (mcp.ts) share byte-identical keys
  via the helper.
- adv-007 / code-reviewer #5: signInternalMcpRequest throws on
  Blob/FormData/ReadableStream/plain-object bodies instead of silently
  JSON.stringify'ing them (which would produce a hash that can't match
  the wire bytes).
- code-reviewer #9: timestamp regex tightened to ^[0-9]{1,15}$ so
  future ms-precision timestamps don't silently truncate through Number().
- code-reviewer #8: MCP_INTERNAL_HMAC_SECRET preflight in runProPreChecks
  surfaces config errors at auth-resolution rather than mid-tool-fetch.

NITPICK:
- code-reviewer #6: rewritten readNegCache comment.
- code-reviewer #7: malformed-body error now reports reason
  'malformed_request' instead of misleading 'auth_401'. New
  RequestReason value added to usage.ts enum.

30 new tests + ~14 adapted; full convex suite 247/247, edge/server
suites 254/254. tsc clean across both tsconfig.json and tsconfig.api.json.

Plan: docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md

* chore(pro-marketing): regenerate /pro bundle for U10 catalog copy

U10's productCatalog.ts added an mcpAccess feature flag and updated the
Pro plan description copy to "MCP access for Claude Desktop & other AI
clients (50 calls/day)". scripts/generate-product-config.mjs cascades
that copy into pro-test/src/generated/tiers.json, which the /pro
marketing app builds into public/pro/assets/.

Vercel does NOT rebuild pro-test on deploy — public/pro/ ships whatever
is committed. Pre-push hook auto-ran the build and produced a new bundle
hash; this commit lands the regenerated artifacts so deploy picks them up.

Plan unit U10 (follow-on artifact).

* fix(pro-mcp): apply external review round-2 findings (P1+P2+P3)

Round-2 review on PR #3646 surfaced 5 must-fix issues:

P1 — vercel.json `(?:...)` rejected by Vercel CI:
- Replace `mcp-grant(?:\\.html)?` with explicit `mcp-grant\\.html|mcp-grant`
  inside the SPA catch-all negative-lookahead (lines 18 + 134).
- Split the headers `/mcp-grant(?:\\.html)?` source rule into two explicit
  entries — Vercel's path-to-regexp `source` field doesn't accept `(?:...)`
  with `?` quantifier as a standalone source.
- Update tests/deploy-config.test.mjs expectations to match.

P1 — api/api-route-exceptions.json points at deleted file:
- Update the entry path from `api/oauth/token.js` (deleted in U6) to
  `api/oauth/token.ts`. `node scripts/enforce-sebuf-api-contract.mjs`
  was failing pre-push CI.

P1 — mcpAccess migration gap on existing entitlement rows:
- convex/entitlements.ts now read-time merges `getFeaturesForPlan(planKey)`
  defaults under stored features. Pre-U10 rows lacking `mcpAccess` see the
  catalog default surfaced immediately, with NO wait for the next Dodo
  webhook to rewrite the row. Stored features still win on conflict
  (preserves per-user overrides). Mirrored explicitly in
  convex/mcpProTokens.ts:55 (issueProMcpToken's direct ctx.db.query path
  computes the same merge inline since it bypasses the query handler).

P2 — grant/issue path missing mcpAccess gate (let tier-1-without-mcpAccess
users complete OAuth then fail every tools/call at the gateway):
- mcp-grant-context.ts:95, mcp-grant-mint.ts:236, authorize-pro.ts:356,
  convex/mcpProTokens.ts:55 now ALL gate on `tier ≥ 1 && mcpAccess === true`,
  matching the downstream MCP-edge runProPreChecks check. Widens the
  `getEntitlements` deps return type at all three handlers.

P3 — inline theme script blocked by global CSP:
- mcp-grant.html:34 inline `<script>` is removed (global CSP at vercel.json:92
  is hash-allowlist-based and we don't allowlist this page's hashes).
- Theme application moved into the bundled module at src/mcp-grant-main.ts
  (top of file, runs on module evaluation). Brief default-theme flash on
  light-preference users is acceptable for a transient consent UI.

10 new tests covering: mcpAccess: false at each of the 4 gates,
mcpAccess: undefined (legacy row) at each of the 4 gates, and the
read-time catalog merge in both directions (legacy gets default,
override preserved).

Full gate post-fix:
- Convex 249/249 (+2 new merge tests).
- Edge/server 280/280 (+10 new gate tests).
- tsc both configs clean.
- sebuf contract clean (was failing before P1.2).
- Sentry coverage clean.
- deploy-config tests assert new explicit-source rules.

Plan: docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md

* fix(entitlement-cache): treat pre-U10 cache entries lacking mcpAccess as stale

Reviewer round-2 P2 (cache layer): _getEntitlementsImpl returned hot
Redis cache entries as-is, bypassing the read-time catalog merge that
convex/entitlements.ts:50 applies on the Convex read path. Paying users
with cache entries written before plan 2026-05-10-001 U10 deployed see
mcpAccess !== true at the grant/MCP gates and are blocked for up to
the 15-min cache TTL.

Cache predicate now also requires `typeof features.mcpAccess === 'boolean'`.
Legacy entries (mcpAccess: undefined) fall through to Convex, which
returns the merged shape and the cache is rewritten with the post-U10
layout. Self-healing, bounded to one extra Convex round-trip per
affected user during the migration window.

Targeted choice over alternatives:
- Importing convex/config/productCatalog into server/_shared to apply
  the merge at the cache layer would pull non-edge-safe deps. Rejected.
- Treating ALL cached entries as stale would defeat the cache layer.
  Rejected.
- typeof check on the new field is the minimum delta that fixes the
  migration window without restructuring.

Test fixture in server/__tests__/entitlement-check.test.ts updated:
makeEntitlements now includes mcpAccess (tier ≥ 1 → true). Two new
focused tests:
1. Legacy cache entry without mcpAccess → cache rejected → Convex
   round-trip → result returned (verifies fetch called once).
2. Post-U10 cache entry WITH mcpAccess → cache honored → no Convex
   round-trip (verifies fetch called zero times).

Convex 251/251 (+2 new cache tests).

Plan: docs/plans/2026-05-10-001-feat-pro-mcp-clerk-auth-quota-plan.md
koala73 added a commit that referenced this pull request May 25, 2026
Reviewer (PR #3908 P2/P3) caught two real issues:

bg.json — fallbackBadge contained 'živоте серии', a Latin-Cyrillic
mixed-script word ('živ' Latin + 'оте' Cyrillic). Now uses standard
Bulgarian 'серии на живо' (live series). Also fixed the same key's
fallbackTooltip where 'семе' (plant-seed) was a misread of the
tech-sense 'data seed' — replaced with 'Живите данни' (live data).

th.json — fallbackBadge / fallbackTooltip both contained 'สดใจ'
('cheerful' — literal: fresh-hearted), an artefact where the model
appended 'ใจ' (heart) to 'สด' (fresh/live). Replaced with the
unambiguous Thai-tech loanword 'เรียลไทม์' (real-time).

Programmatic sweep for the bg-style mistake (adjacent Latin/non-Latin
chars within a single word) across the full 6-baseline-keys × 20-
locales matrix surfaced only the bg one; ko 'ECB가' tripped the
scanner but is just an acronym + Korean particle (false positive).
Thai 'สดใจ' is a semantic error within Thai script — not detectable
by mixed-script heuristics; relied on the reviewer's read.

Prompt rule #8 (P3): the previous version said _few is "2-4" and
_many is "5+" for Slavic locales, which silently glosses over the
CLDR teen-case exceptions (e.g. Russian/Croatian _few requires
n%100 != 12-14). Reworded to explicitly tell the model to follow
CLDR rules exactly and NOT use the simplified rules of thumb. The
code path already delegates to Intl.PluralRules for category
selection — this just stops the prompt from biasing the model
toward incorrect teen-case morphology in future runs.
koala73 added a commit that referenced this pull request May 25, 2026
… variants (#3908)

* feat(i18n): make translate-locales.mjs CLDR-plural-aware

Before: the script diffed each locale against EN at the flat-key level,
so the only plural variants ever generated were the two EN itself
defines (_one, _other). Locales whose CLDR plural rules require
additional categories (_few/_many in Slavic; _zero/_two/_few/_many in
Arabic; _many in Romance for fractional counts) silently shipped with
their non-_one/_other count values falling back to the English string.

Now: per non-EN locale, the script consults
`Intl.PluralRules(loc).resolvedOptions().pluralCategories` to discover
the required CLDR categories, fans out each EN pluralized base
(any `<base>_one` + `<base>_other` pair) into one expected key per
category, and adds the missing ones to the translation batch. The
prompt grew one rule (#8) telling the model that suffixes name the
CLDR category whose morphology it should inflect — Claude already
knows CLDR semantics so no per-locale lookup table is needed.

Helpers added:
- getPluralCategories(loc): wraps Intl.PluralRules; defaults to
  ['one','other'] on any unknown tag for safety.
- findPluralBases(enFlat): returns the bases where EN has both _one
  and _other (the only shape EN can express); the same set is reused
  for every locale.
- expectedKeysForLocale(enFlat, pluralBases, categories): the
  per-locale expected-key set used both by the missing-key detector
  in the main loop and by the post-write completeness scan.

Net behaviour on a locale that's already correct: zero change (the
existing _one/_other keys satisfy expected and `missing` stays
empty). On a fresh locale or any locale that's missing a CLDR
category, only the absent keys are filled — existing translations
are never overwritten (same as before).

* fix(i18n): backfill missing CLDR plural variants + 6 stale baseline keys

Re-ran the now-plural-aware translate-locales.mjs against all 20
non-EN locales. 360 missing keys total:

- 6 baseline keys added to en.json since the last full backfill,
  propagated to every locale:
    components.cii.methodologyLink + _methodologyLink_translatorNote
    components.diseaseOutbreaks.methodologyNote
    components.fsi.cissStale
    components.progressCharts.fallbackBadge + fallbackTooltip

- CLDR plural variants filled per locale's required categories:
    ar (zero/one/two/few/many/other):  +64 plural keys
    cs (one/few/many/other):           +32 plural keys
    pl (one/few/many/other):           +32 plural keys
    ru (one/few/many/other):           +32 plural keys
    ro (one/few/other):                +16 plural keys
    es/fr/it/pt (one/many/other):      +16 each (decimal counts)

Verified post-write: pure additions, zero existing keys modified
across all 20 files (validated with set-diff of every flattened
key/value vs HEAD). Spot-checked the tricky morphology:

  ru sources: один=источник, few=источника, many=источников ✓
  pl sources: один=źródło, few=źródła, many=źródeł ✓
  cs sources: один=zdroj, few=zdroje, many=zdrojů ✓
  ar sources: dual=مصدران (two), pl=مصادر, sg-after-large=مصدر ✓
  ro sources: one=sursă, few/other=surse ✓

User-visible effect: counts like 2, 3, 4 in Czech/Polish/Russian/
Romanian/Croatian and 2/0/3-10 in Arabic now render in the actual
locale instead of falling through to English via i18next's
fallbackLng. Closes the gap that PR #3887 surfaced for Croatian.

Known quality note (not fixed here — flagged for native review):
the Arabic `designations` plurals mix two valid Arabic roots
(tasmiya / ta'yīn) across categories. Each form is correct MSA on
its own; the inconsistency is cosmetic.

* fix(i18n): repair bg/th baseline-string artefacts + reword plural prompt

Reviewer (PR #3908 P2/P3) caught two real issues:

bg.json — fallbackBadge contained 'živоте серии', a Latin-Cyrillic
mixed-script word ('živ' Latin + 'оте' Cyrillic). Now uses standard
Bulgarian 'серии на живо' (live series). Also fixed the same key's
fallbackTooltip where 'семе' (plant-seed) was a misread of the
tech-sense 'data seed' — replaced with 'Живите данни' (live data).

th.json — fallbackBadge / fallbackTooltip both contained 'สดใจ'
('cheerful' — literal: fresh-hearted), an artefact where the model
appended 'ใจ' (heart) to 'สด' (fresh/live). Replaced with the
unambiguous Thai-tech loanword 'เรียลไทม์' (real-time).

Programmatic sweep for the bg-style mistake (adjacent Latin/non-Latin
chars within a single word) across the full 6-baseline-keys × 20-
locales matrix surfaced only the bg one; ko 'ECB가' tripped the
scanner but is just an acronym + Korean particle (false positive).
Thai 'สดใจ' is a semantic error within Thai script — not detectable
by mixed-script heuristics; relied on the reviewer's read.

Prompt rule #8 (P3): the previous version said _few is "2-4" and
_many is "5+" for Slavic locales, which silently glosses over the
CLDR teen-case exceptions (e.g. Russian/Croatian _few requires
n%100 != 12-14). Reworded to explicitly tell the model to follow
CLDR rules exactly and NOT use the simplified rules of thumb. The
code path already delegates to Intl.PluralRules for category
selection — this just stops the prompt from biasing the model
toward incorrect teen-case morphology in future runs.

* fix(i18n): address Greptile P2 findings on PR #3908

Three tooling fixes + two data fixes in response to Greptile review.

Tooling (scripts/translate-locales.mjs):

1. getPluralCategories null guard. The previous try/catch only caught
   constructor throws (RangeError on unknown locale tag), not the case
   where `resolvedOptions().pluralCategories` itself is absent (older
   Node where the property predates the spec). That `undefined` then
   crashed the next `for (const cat of categories)` mid-run with a
   TypeError. Now uses `?? ['one','other']` to cover both paths.

2. validateTranslation URL preservation. The model previously could
   silently paraphrase or drop URLs/paths embedded in translation
   sources (e.g. the methodologyLink `/docs/methodology/cii-risk-scores`)
   and the validator wouldn't catch it. Added a URL/path multiset check
   alongside the existing interpolation-token and HTML-tag checks. The
   path pattern requires the first segment after `/` to start with a
   letter so it doesn't fire on number fractions (`50/100`) or
   interpolation tokens (`{{count}}/{{total}}`). Verified against 6
   cases (preserve/drop/rewrite path; ignore fraction/interpolation;
   match https URL).

3. Skip leading-underscore "private" keys. Convention: any dotted-path
   segment starting with `_` is a translator-instruction key meant to
   stay in English so translators reading the raw files can understand
   it (e.g. `_methodologyLink_translatorNote` is a TODO note about its
   sibling `methodologyLink`). Sending those through the model produced
   the visible mistranslations in ar/ja/pt/th in the previous run.
   Added `isPrivateKey()` filter at the expected-keys layer so private
   keys never enter the missing-batch or the post-write coverage scan.

Data:

4. Reverted `components.cii._methodologyLink_translatorNote` to the
   English source in ar.json, ja.json, pt.json, th.json (the 4 of 20
   locales that the model translated; the other 16 already kept EN).

5. Aligned time-ago plural variants to their `_other` form across cs,
   es, fr, ro (22 strings total). The previous backfill chose more
   idiomatic prepositional forms (`před {{count}}h` / `hace {{count}}h`
   / `Il y a {{count}}h` / `acum {{count}}h`) while the pre-existing
   `_one`/`_other` use postpositional forms (`{{count}}h zpět` /
   `{{count}}h atrás` / `il y a {{count}}h` / `{{count}}h în urmă`).
   i18next picks exactly one variant per render, so users would have
   seen mixed-style timestamps depending on count. Greptile flagged
   only the fr capitalization symptom (`Il y a` vs `il y a`); the
   project-wide scan I ran surfaced the same wider issue in cs/es/ro
   so this fix covers all four locales.

Note on radiationWatch/thermalEscalation time-ago strings in fr.json:
the pre-existing `_one`/`_other` values are still English ("{{count}}h
ago") rather than French — a separate pre-existing translation gap not
in scope of this PR. Aligning `_many` to match `_other` keeps the
consistency rule but inherits the English text for those 5 keys;
fixing the root French translation there should be a follow-up.
koala73 added a commit that referenced this pull request May 26, 2026
…ns + queries + service + UI (#3621)

* feat(country-codes): add toIso2 normalizer for alpha-2/3/name input (U1)

Foundation for the followed-countries watchlist primitive. Normalizes
any country input form (ISO-3166 alpha-2, alpha-3, lowercase, common
country names) to canonical alpha-2 uppercase. Returns null on
unrecognized input.

Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U1

* feat(convex): add followedCountries + aggregate counter tables (U12)

Two new Convex tables for the followed-countries watchlist primitive:

- followedCountries: { userId, country, addedAt } indexed by_user,
  by_country, by_user_country. Per-country reverse lookup via
  by_country enables future fan-out (digest, breaking-news relay).

- followedCountriesCounts: { country, count, updatedAt } indexed
  by_country. Aggregate counter maintained atomically by U13
  mutations so public countFollowers query is O(1) per call rather
  than O(n) on by_country.collect().

Constants in convex/constants.ts: FREE_TIER_FOLLOW_LIMIT=3 (server-
authoritative cap), MAX_MERGE_INPUT=100 (anti-abuse ceiling),
COUNTRY_COUNT_PRIVACY_FLOOR=5 (returned-as-zero threshold for
public counts).

No migration; both tables empty on creation.

Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U12

* feat(convex): add followedCountries mutations + ISO-2 registry validator (U13)

Three server-authoritative mutations on the new followedCountries
table, with atomic counter maintenance for the aggregate
followedCountriesCounts table:

- followCountry({country}): auth + ISO-2 registry validate +
  idempotent on (userId, country) + free-tier cap (server-enforced
  via ConvexError({kind:'FREE_CAP'})) + atomic counter +1.
- unfollowCountry({country}): auth + validate + idempotent +
  atomic counter -1 (max(0, ...) defensive).
- mergeAnonymousLocal({countries}): auth + MAX_MERGE_INPUT ceiling
  (anti-abuse) + ISO-2 registry filter + first-seen dedupe + bounded
  accept for free users (cap-fitting; over-cap → droppedDueToCap[])
  + atomic counter +N.

ISO-2 registry validator at convex/lib/iso2.ts mirrors the canonical
alpha-2 set from src/utils/country-codes.ts. Both registries must
stay in lockstep (documented inline).

All errors typed ConvexError({kind, ...}) with object data per
the convex-error-string-data-strips-errordata-on-wire memory.

32 new tests covering: auth/validation, free-tier cap (under, at,
exceeded), idempotency for both follow + unfollow, counter
correctness across all paths (never goes negative), mergeAnonymous
with grandfather rejection / partial accept / oversized input /
duplicate inputs / mixed valid+invalid.

Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U13

* feat(convex): followedCountries queries + relay endpoint (U14)

Three queries + one relay HTTP action over the followedCountries +
followedCountriesCounts tables:

- listFollowed = query({}): auth'd reactive query for current user;
  returns string[] sorted by addedAt asc; [] when no auth identity.
  Drives the client-side reactive subscription (U2/U3).

- countFollowers = query({country}): public no-auth query backed by
  the aggregate counter table — O(1) per call, not O(n) on
  by_country.collect(). Privacy floor (COUNTRY_COUNT_PRIVACY_FLOOR=5)
  returns 0 below threshold to limit follower-set inference. Drives
  future 'X people watching' social-proof UI.

- listFollowersPage = internalQuery({country, cursor?, limit}):
  internal-only paginated cursor on by_country index, limit clamped
  [1, 500]. NEVER exposed publicly (declared as internalQuery, NOT
  query — the typecheck-level privacy boundary). Drives future
  per-country fan-out (digest, breaking-news relay).

- internalListFollowedForUser = internalQuery({userId}): internal
  helper used by the relay endpoint (which has no Clerk identity).

POST /relay/followed-countries HTTP action mirrors the existing
/relay/user-preferences pattern: shared-secret auth via
timingSafeEqualStrings, body {userId}, returns {countries: string[]}.
Used by PR C's brief composer to read followed-countries server-side.

29 new tests covering: per-user reads, sort order, no-auth empty,
counter-table-backed counts, privacy floor edges (4 vs 5), cursor
pagination across multi-page result, limit clamp [1,500],
@ts-expect-error privacy assertion that listFollowersPage is NOT
on api.* (only internal.*), relay 200/400/401 paths.

Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U14

* feat(followed-countries): client service — anonymous mode + entitlement gating (U2)

Single client-side owner of watchlist semantics for the followed-
countries primitive. U2 ships the anonymous (localStorage) path
fully working; signed-in mode plumbing is stubbed with explicit
TODO(U3) markers for the next unit to fill in.

Public API:
- getFollowed(): string[]
- isFollowed(code: string): boolean
- addCountry(input): Promise<FollowMutationResult>
- removeCountry(input): Promise<FollowMutationResult>
- subscribe(handler): unsubscribe
- serviceEntitlementState(): 'pro' | 'free' | 'loading'
- WM_FOLLOWED_COUNTRIES_CHANGED custom event

Discriminated-union return (memory: discriminated-union-over-sentinel-
boolean): { ok: true } | { ok: false, reason: 'DISABLED' |
'INVALID_INPUT' | 'FREE_CAP' | 'ENTITLEMENT_LOADING' |
'HANDOFF_PENDING' | 'STORAGE_FULL' }. Never throws.

Anonymous-vs-loading distinction (Codex deepening round-1 P1):
serviceEntitlementState() returns 'free' when getCurrentClerkUser()
is null, regardless of entitlement state — anonymous users never
block on entitlement loading. Only signed-in users with null
entitlement state enter 'loading'.

Storage: localStorage 'wm-followed-countries-v1' = JSON.stringify(
{ countries: string[] }). NOT enrolled in CLOUD_SYNC_KEYS — the
dedicated Convex table replaces that path for this feature.

Feature flag VITE_FOLLOW_COUNTRIES_ENABLED gates all mutations at
the service layer (refusal at top of addCountry/removeCountry).
Default ON; only '0' disables.

25 tests covering: happy paths, normalization (alpha-3 → alpha-2),
idempotency, FREE_CAP cap enforcement, PRO unlimited, ENTITLEMENT_
LOADING (signed-in only), anonymous-never-loads, feature-flag
DISABLED, corrupt/wrong-shape localStorage, STORAGE_FULL on quota
throw.

Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U2

* feat(followed-countries): sign-in handoff + Convex bridge (U3)

Wires the signed-in mode of the followed-countries service:

- Auth-state listener installed once at app boot. On every Clerk
  user transition, increments _handoffGeneration and captures
  userIdAtStart for any in-flight handoff. Post-await callbacks
  verify both BEFORE clearing localStorage / subscribing —
  resolves the in-flight auth race (Codex deepening round-1 P1).
- Sign-in handoff orchestrator reads localStorage, calls
  api.followedCountries.mergeAnonymousLocal, clears localStorage on
  success, subscribes to listFollowed reactive query.
- handoffPending UX: addCountry/removeCountry return HANDOFF_PENDING
  so FollowButton can show a syncing tooltip; getFollowed unions
  localStorage with the user-scoped subscription snapshot for
  stable display, BUT only if snapshot.userId matches current
  Clerk userId (Codex deepening round-2 P1 — no cross-user leak).
- Sign-out / user-A → user-B: increments _handoffGeneration,
  unsubscribes, CLEARS _lastKnownSubscriptionSnapshot = null,
  resets _handoffState. localStorage retained for next anon session.
- Network failure: _handoffState = 'failed', visibilitychange
  retry. Idempotency means safe to re-run mergeAnonymousLocal.
- Convex error → reason mapping via err.data.kind (memory:
  convex-error-string-data-strips-errordata-on-wire). FREE_CAP
  preserves currentCount + limit.
- Cap-drop event: when mergeAnonymousLocal returns
  droppedDueToCap[], dispatch WM_FOLLOWED_COUNTRIES_CAP_DROP for
  the upgrade-CTA toast (consumed by U4 FollowButton).

24 new sign-in handoff tests covering: empty/corrupt localStorage
skip + clean, free-tier bounded accept with cap-drop event,
network failure → visibilitychange retry, in-flight auth-race
sign-out (gen guard drops result), in-flight user-swap
(userIdAtStart guard drops result), HANDOFF_PENDING blocks writes,
getFollowed user-scoped union, sign-out clears snapshot,
sign-in→sign-out→different-user flow, reactive snapshot updates.

Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U3

* feat(follow-button): reusable star button helper for 3 surfaces (U4)

Single mountable factory used by CountryDeepDivePanel,
CountryIntelModal, and CIIPanel rows in U5. Owns:

- Visual states: outlined-star (unfollowed), filled-star (followed),
  spinner (entitlement loading), hidden (feature flag off).
  At-cap state shows 'Upgrade to follow more' tooltip pre-click.
- Click handler: addCountry/removeCountry. FREE_CAP triggers the
  existing upgrade flow (mirroring notifications-settings.ts lazy-
  import pattern: openSignIn for anon, startCheckout for signed-in,
  fallback to /pro#pricing). HANDOFF_PENDING / DISABLED / loading
  → defensive no-op.
- Reactivity: subscribes to WM_FOLLOWED_COUNTRIES_CHANGED and
  onEntitlementChange; teardown unsubscribes both. Idempotent
  teardown.
- Anonymous-vs-loading distinction (Codex round-2 P1): driven by
  serviceEntitlementState() helper, NOT raw getEntitlementState().
  Anonymous users render interactive (state a/b), only signed-in-
  awaiting-snapshot enters spinner state.

Adds isFollowFeatureEnabled() exported helper to the service so
the button gates on the same source of truth as the service.

18 tests covering visual states, anonymous click flow, entitlement-
loading window with PRO/FREE resolution, subscription + teardown.

Cap-drop toast (WM_FOLLOWED_COUNTRIES_CAP_DROP from U3) is NOT
wired here — left as TODO for App-level toast service. CSS is
semantic class names only; styling lands in PR B.

Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U4

* feat(panels): mount FollowButton on Deep Dive, Intel Modal, CII rows (U5)

Surfaces the followed-countries primitive on the three PR-A entry
points. No other behavior changes (pin-to-top, filter chips,
brief weighting are PR B / PR C).

CountryDeepDivePanel:
- FollowButton (size: md) inserted in header next to title
- Teardown wired into resetPanelContent() + hide(); idempotent

CountryIntelModal:
- FollowButton (size: md) in header between country name and level
- Mount on show(); teardown on showLoading()/hide(); idempotent

CIIPanel:
- FollowButton (size: sm) as first child of every .cii-country row
- Map<countryCode, teardown> tracks per-row mounts; cleared on
  every wholesale rebuild + on destroy() to prevent listener leaks
- Click stopPropagation so star toggle does NOT also trigger
  row-level onCountryClick (mirrors the existing cii-share-btn
  pattern)

Semantic class names only (.wm-follow-btn + per-host wrappers);
CSS lands in PR B's UX polish.

Verification: npm run typecheck + typecheck:api clean; full
test:data suite still green (7898/7898).

Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U5

* fix(followed-countries): convex server-side hardening pass (Phase 1)

P3 #21: followCountry now reads entitlement tier BEFORE collecting all
user rows; PRO callers skip the O(N) `.collect()` of followedCountries
since they have no cap to check. Free users are unchanged.

P2 #12: countFollowers privacy-floor doc/code alignment — comment now
matches the `<` comparator (1-4 followers → 0; 5+ → exact count).

P2 #19: /relay/followed-countries userId validation tightened to mirror
/relay/user-preferences rigor — non-empty string with bounded length
(<=256 chars) instead of just truthy. Mitigates oversized / non-string
abuse vectors.

P2 #13: ISO-2 dual-registry parity test upgraded from size-only
(`.size === 239`) to set-equality. Catches drift where one side has,
e.g., 'XK' and the other has 'EU' with the same total count.
`ISO2_TO_ISO3` is now exported from `src/utils/country-codes.ts`.

Tests: 278 → 283 (added 1 set-equality, 1 PRO-skip-collect, 3 relay
userId validation tests).

Plan/Review reference: ce-code-review run 20260502-195816-dae403d7

* fix(followed-countries): service-core hardening pass (Phase 2)

P1 #3: _runHandoff catch now uses _extractConvexErrorKind.
Permanent ConvexError kinds (INPUT_TOO_LARGE, EMPTY_INPUT,
UNAUTHENTICATED) skip the visibilitychange retry path: clear
localStorage, transition to new 'failed-permanent' state, install the
reactive subscription so signed-in reads still work. Transient errors
(network / undefined kind) still retry.

P1 #4: max-retry counter (5) + exponential backoff (1, 2, 4, 8, 16
seconds) gate the visibilitychange retry path. After exhaustion the
state flips to 'failed-permanent' and no further retries are scheduled.
_clearFailedHandoffForTests() exposed as the test recovery hook.
Production has no equivalent today; sign-out / sign-in starts a fresh
generation. Test seam _setHandoffBackoffForTests collapses the backoff
schedule so tests don't have to wait seconds.

P1 #5: signed-in addCountry/removeCountry now return HANDOFF_PENDING
when the convex client is null instead of falling back to localStorage.
Stale partial-writes that never reconcile with the authoritative table
are no longer possible in signed-in mode. _setDepsForTests gains a
'force-null' literal so test injection can return null without falling
through to the production importer.

P1 #6: dropped the `if (existing.includes(code)) return {ok:true}`
short-circuit on the SIGNED-IN branch of addCountry/removeCountry. The
Convex mutation is itself idempotent and authoritative; the client-side
snapshot is eventually consistent and could lie (e.g., another tab just
unfollowed). Anonymous-mode short-circuit retained because localStorage
IS the source of truth there.

P1 #8: replaced unknown-typed ConvexClientLike/ConvexApiLike with
FunctionReference<...> generics from convex/server. Mutation arg/result
shapes (e.g., {country: code} vs {countries: code}) are now checked at
the call site, eliminating the entire typo class.

P1 #9: imports MergeAnonymousLocalResult from convex/followedCountries
instead of hand-rolling an inline subset.

P1 #10: cross-tab `storage` event listener installed alongside the
auth-state listener. Filters on key === FOLLOWED_COUNTRIES_STORAGE_KEY
and re-dispatches as WM_FOLLOWED_COUNTRIES_CHANGED so FollowButtons in
other tabs re-render after a Tab-A mutation.

P1 #11: signed-in addCountry/removeCountry capture {userIdAtStart,
genAtStart} BEFORE the await, then call _authStillMatches() after the
await. A sign-out / user-swap mid-mutation surfaces as HANDOFF_PENDING
instead of letting user-A's success "land" while we're already user-B.

P2 #20: empty-handoff path defers dispatchChanged until the first
reactive snapshot lands. Tracks _initialSnapshotReceived; getFollowed()
falls back to localStorage during the gap between 'complete' being set
and the first onUpdate callback firing. Avoids a brief flash of
empty-list rendering during the subscription warm-up window.

Tests: data 7898 → 7911 (added 13 — INPUT_TOO_LARGE/EMPTY_INPUT/
UNAUTHENTICATED permanent-kind handling, plain-error transient guard,
max-retry exhaustion + recovery hook, client-null HANDOFF_PENDING for
both add and remove, P1 #6 stale-snapshot non-short-circuit, cross-tab
storage event re-dispatch x2, post-await auth re-check, empty-handoff
deferred dispatch). Convex 283/283 unchanged.

Plan/Review reference: ce-code-review run 20260502-195816-dae403d7

* fix(follow-button): UI hardening — exhaustive switch + inFlight guard (Phase 3)

P2 #16: added `assertNever(result)` default branch to the FollowButton
onClick switch on `FollowMutationResult.reason`. When every variant is
handled by a `case`, `result` narrows to `never` at the default; adding
a new reason to `FollowMutationResult` will widen the residual type and
produce a TS2345 ('not assignable to never') at typecheck time. Catches
the future-variant bug class at compile time, with a runtime fallback
for malformed test fakes.

P2 #17: introduced an `inFlight` boolean closed over by the click
handler. When a mutation is already pending, additional clicks are
dropped silently (no duplicate addCountry/removeCountry fires). Cleared
in finally{} so a thrown service-layer error doesn't latch the button.
Without this, a rapid double-click on an unfollowed button produced
TWO follow mutations — the service is idempotent on (user, country)
but the second add was wasted network + counter-increment work and a
toggle pattern (click on, click off) could land in the unintended
state.

Tests: data 7911 → 7913 (added inFlight rapid-double-click suppression
test + assertNever runtime-guard sanity test).

Plan/Review reference: ce-code-review run 20260502-195816-dae403d7

* chore(env): document VITE_FOLLOW_COUNTRIES_ENABLED in .env.example (Phase 4)

P2 #18: adds the missing .env.example entry for the followed-countries
feature flag. Mirrors the format of sibling flags (VITE_CLOUD_PREFS_ENABLED)
and clarifies the default-on-unless-'0' semantics enforced by
`isFeatureFlagEnabled()` in src/services/followed-countries.ts.

Plan/Review reference: ce-code-review run 20260502-195816-dae403d7

* fix(followed-countries): per-user serialization doc — close TOCTOU cap-bypass (P0)

Codex round-3 review run 20260502-195816-dae403d7 (adv-001 / adv-002) flagged a
P0 cap-bypass on `followCountry` and `mergeAnonymousLocal`. Convex per-document
OCC tracks reads at the DOCUMENT level, not at the index-range level — so two
parallel `followCountry` mutations from the same user can both read empty/
under-cap from `followedCountries` (an index range), both pass the cap check,
and both insert. Cap bypass + potential duplicate (userId, country) rows. The
same shape applies to `mergeAnonymousLocal` from N tabs at sign-in.

Mitigation: a per-user serialization document (new table
`followedCountriesUserMeta`) that every mutation reads AND writes. Convex's
real OCC then forces concurrent same-user mutations to serialize on this row:
the loser of the race retries, re-reads the post-winner state (which now
contains the winner's `(userId, country)` row + bumped count), and either
passes correctly (still under cap), throws `FREE_CAP`, or returns idempotent.
The denormalized `count` field also makes the cap check O(1) — happy side
effect that closes P3 #21 (`.collect()` for cap purposes is gone).

Schema: new table `followedCountriesUserMeta` keyed by userId, with a
denormalized `count` and `updatedAt`. Convex auto-deploys schema before
handlers in the same push, so single-PR shipping is safe.

Mutations:
- `followCountry`: read meta first, use `count` for cap check (free tier
  only), patch/insert meta at the END after row insert + counter +1.
  Idempotent (already-followed) path skips meta write — no observable
  change, no race to lose.
- `unfollowCountry`: read meta first, decrement to `Math.max(0, count - 1)`
  at the end. Idempotent (no-row) path skips meta write.
- `mergeAnonymousLocal`: read meta for the cap denominator, `existingRows`
  still required for the dedup set. Patch meta with `existingCount +
  accepted.length` at the end. Skip meta write when `accepted.length === 0`.

Tests (+9, total 283 → 292):
- Concurrent same-user same-country `Promise.all` → exactly 1 row + 1
  idempotent response.
- Concurrent same-user cap-boundary (2 seeded + 2 attempts on cap=3) →
  exactly 1 fulfilled, 1 rejected with FREE_CAP, final ≤ cap.
- Concurrent mixed follow/unfollow → consistent end state, parity invariant.
- Concurrent `mergeAnonymousLocal` from 5 tabs (free user) → final ≤ cap,
  no duplicate (userId, country) rows.
- Concurrent `mergeAnonymousLocal` from 5 tabs (PRO user) → exactly the
  deduped union, all per-country counters at 1.
- Meta-count parity invariant after mixed mutation sequence.
- Idempotent paths (followCountry on existing, unfollowCountry on absent)
  don't bump/decrement meta count.
- FREE_CAP throw rolls back all writes (transaction atomicity).

Concurrency-test caveat documented in the test file: convex-test 0.0.43's
TransactionManager (node_modules/convex-test/dist/index.js:1268) takes a
single `_waitOnCurrentFunction` lock at top-level mutation begin, so
`Promise.all` of mutations runs strictly sequentially in the mock. There
is NO real OCC retry simulator. Tests therefore prove the FINAL-STATE
INVARIANT — even when the second mutation runs back-to-back against the
post-winner state, cap/idempotency/meta-parity hold. In production the
Convex platform's OCC layer turns the same final-state invariant into the
cap-bypass guarantee. See memory `convex-occ-retry-vs-app-cas-conflict-
different-layers` for the layer separation.

Test helper `seedFollowedCountries(t, userId, codes)` added to seed both
the rows AND the user-meta row in parity for tests that bypass the
mutation API.

Files:
- convex/schema.ts: +`followedCountriesUserMeta` table + index.
- convex/followedCountries.ts: +`readUserMeta` / `writeUserMeta` helpers,
  meta read/write inserted into all 3 mutation paths, expanded inline docs
  on the OCC mechanism.
- convex/__tests__/followed-countries-mutations.test.ts: +9 concurrent /
  parity tests + `seedFollowedCountries` helper + `readUserMetaCount`
  helper + caveat block on convex-test's serialized mock.

Verification: `npm run typecheck` ✅, `npm run typecheck:api` ✅,
`npm run test:convex` 292 / 292 ✅.

* fix(followed-countries): pre-seeded sharded lock — close nested TOCTOU on user-meta create (P0 v2)

Codex round-4 review of PR #3621 caught that the round-3 P0 fix
(followedCountriesUserMeta per-user lock) did not actually close the
cap-bypass: the meta document is created LAZILY on first mutation, and
Convex per-document OCC tracks reads at the document level, not at the
empty-index-range level. Two parallel first-ever mutations from the
same brand-new user could both read meta=undefined and both INSERT,
producing duplicate meta rows that break the next .unique() read AND
re-open cap-bypass / counter double-increment.

Fix: Approach B (pre-seeded sharded lock).

  - New table followedCountriesShards with one row per shard id in
    [0, SHARD_COUNT) (SHARD_COUNT=64 in convex/constants.ts),
    pre-seeded by _seedShards.
  - convex/lib/shards.ts::userIdToShard(userId) — deterministic djb2
    hash. Frozen contract; changing it would silently remap users.
  - Every followCountry / unfollowCountry / mergeAnonymousLocal
    mutation reads its shard at the top (throws SHARDS_NOT_SEEDED loud
    if missing — operator error, never silent), then patches
    lastTouchedAt at the end of any non-idempotent path. The
    read+write pair on an ALREADY-EXISTING document is what triggers
    Convex OCC to serialize concurrent same-user mutations.
  - Tier 2 (existing user-meta row) kept additionally for the O(1)
    cap-check denominator and parity invariant — but its lazy create
    is now race-free under the shard lock.
  - Daily cron followed-countries-shards-seed at 03:00 UTC re-runs
    _seedShards (idempotent) so a missed deploy-seed step self-heals.
  - Public seedShards mutation for operator CLI: npx convex run --prod
    followedCountries:seedShards after a fresh deploy without waiting
    for the cron.

Tests added (mutations test file, +6 tests, 292 -> 298):
  - first-ever follow on a brand-new user creates exactly 1 meta row
  - two back-to-back mergeAnonymousLocal calls on a brand-new user ->
    one meta row, no duplicates
  - operator running _seedShards after partial seed completes
    idempotently (0 steady-state, plugs holes only)
  - SHARDS_NOT_SEEDED throws when shards table is empty (operator
    error path, all three mutations)
  - public seedShards mutation reachable via operator CLI surface
  - userIdToShard determinism + range invariant

Test fixtures (makeT() helper) call _seedShards before any mutation
runs, mirroring the production deploy + cron post-condition.

Files changed:
  - convex/constants.ts: SHARD_COUNT=64
  - convex/schema.ts: followedCountriesShards table + index
  - convex/lib/shards.ts (new): userIdToShard djb2
  - convex/followedCountries.ts: shard read/write at every mutation,
    _seedShards (internal) + seedShards (public operator) mutations
  - convex/crons.ts: daily _seedShards cron at 03:00 UTC
  - convex/__tests__/followed-countries-mutations.test.ts: makeT()
    helper, 6 new tests
  - convex/__tests__/followed-countries-queries.test.ts: makeT()
    helper (queries-test invokes followCountry, also needs shards)

References: Codex review run /private/tmp/worldmonitor-pr3621-review/
findings_round4.md (P0 v2). Memory:
convex-occ-retry-vs-app-cas-conflict-different-layers (layer
separation: this is the app-side serialization layer that lets Convex
OCC do its job).

* fix(followed-countries): treat UNAUTHENTICATED as transient in handoff retry (P1)

Codex round-4 P1: subscribeAuthState emits the current signed-in
state IMMEDIATELY on subscribe, but Convex auth is not yet ready (the
JWT has not been attached to the Convex client at that tick).
mergeAnonymousLocal fires before Convex sees the auth -> throws
ConvexError({kind:'UNAUTHENTICATED'}).

The previous classification (Phase-2 P1 #3) put UNAUTHENTICATED in the
PERMANENT-error list alongside INPUT_TOO_LARGE / EMPTY_INPUT, so every
transient auth lag cleared localStorage and lost the anonymous follows.

Two-part fix:

(a) Treat UNAUTHENTICATED as TRANSIENT, not permanent.
    _runHandoff catch path no longer routes UNAUTHENTICATED to
    failed-permanent + removeLocalStorage. It falls through to
    _markFailedAndScheduleRetry, which arms the visibilitychange retry
    and counts toward MAX_HANDOFF_RETRIES (5). A genuinely-stuck auth
    mismatch eventually flips to failed-permanent after the budget is
    exhausted, same as the network-failure path.

(b) Defer the merge until Convex auth is ready.
    waitForConvexAuth() exists at src/services/convex-client.ts:79 —
    it resolves when Convex's setAuth callback confirms the client is
    authenticated, with a 10s timeout. We import it and await it BEFORE
    the mergeAnonymousLocal call so the typical race never fires at
    all. On timeout we still attempt the call; the catch from (a)
    treats any resulting UNAUTHENTICATED as transient and the
    visibilitychange retry wins once Convex catches up.

Test seam: _waitForConvexAuthFn module-level binding + new
_setDepsForTests({waitForConvexAuth}) override so tests can drive the
deferred-by-auth flow without going through the real Convex client.

Tests added (tests/followed-countries-sign-in-handoff.test.mjs,
+3 new tests, 1 modified — 37 -> 40 in this file):

  - first call throws UNAUTHENTICATED, visibility retry succeeds ->
    final state has merged data, localStorage cleared, no follows lost
    (the canonical scenario this fix targets)
  - UNAUTHENTICATED IS counted toward MAX_HANDOFF_RETRIES — 5
    consecutive UNAUTHENTICATED throws -> failed-permanent (proves
    runaway-retry guard intact for genuinely-stuck auth)
  - waitForConvexAuth is awaited BEFORE the merge call (proves
    deferred-by-auth path is wired correctly)

Modified: the original "UNAUTHENTICATED -> 'failed-permanent';
localStorage cleared" test is rewritten to assert the new behavior
(state='failed', retry armed, localStorage retained) with an inline
comment explaining the previous behavior was wrong.

Files changed:
  - src/services/followed-countries.ts: import waitForConvexAuth from
    convex-client, _waitForConvexAuthFn seam, await before merge,
    drop UNAUTHENTICATED from permanent-error branch
  - tests/followed-countries-sign-in-handoff.test.mjs: 1 modified +
    3 new tests

References: Codex review run /private/tmp/worldmonitor-pr3621-review/
findings_round4.md (P1). waitForConvexAuth helper found at
src/services/convex-client.ts:79 — exists today and is used by the
existing entitlement subscription path; this fix wires it into the
followed-countries handoff for the same reason.

* fix(ci): seed followedCountries shards on Convex deploy (P1)

The followCountry / unfollowCountry / mergeAnonymousLocal mutations
throw SHARDS_NOT_SEEDED if followedCountriesShards is empty. Today the
seed runs only via the 03:00 UTC daily cron, so a deploy landing at
04:00 UTC would leave the feature broken for ~23h until the next
cron tick. Run npx convex run --prod followedCountries:_seedShards
inline after npx convex deploy --yes so the table is populated before
any traffic hits the new mutations. Idempotent: existing shard rows
are skipped, only missing ids in [0, SHARD_COUNT) are inserted.

* fix(followed-countries): race-tolerant shard seed + dedupe cron + remove public seed (P1)

Two stacked P1 issues from Codex round-3 review of PR #3621:

1. Public seedShards mutation was unauthenticated — any browser
   ConvexHttpClient could call it. Removed; the post-deploy CI step now
   targets the internal _seedShards directly via
   `npx convex run --prod followedCountries:_seedShards` (npx convex run
   resolves internal functions by file:export path).

2. _seedShards has a TOCTOU race: two simultaneous calls against an
   empty table both read empty, both insert the full range, producing
   2 rows per shardId. Previously readShardOrThrow used .unique() which
   throws on duplicates → bricks the affected shard for all users
   hashing to it.

Approach D (real-world correct): make readShardOrThrow tolerant of
duplicates via .first() (returns oldest by _creationTime tiebreaker, so
OCC contention is preserved across all in-flight mutations on that
shard during the duplicate window) AND add a daily _dedupeShards cron
that deletes extras keeping the oldest row per shardId. Tests:

- duplicate shard rows: mutation succeeds, counter parity holds
- _dedupeShards: zero dups → no-op; N dups → reduces to 1 row per
  shardId, oldest survives
- _seedShards idempotent under back-to-back concurrent re-run
- public seedShards no longer exported (source-text negative assertion)

Net: 298 → 302 tests, all green.

* feat(follow-button): minimal CSS for star button + host layouts (PR A foundation)

Third-pass review P2: PR A's src/utils/follow-button.ts:220 emits
.wm-follow-btn* markup mounted on three live surfaces (CIIPanel,
CountryDeepDivePanel, CountryIntelModal) but no CSS shipped — buttons
rendered as native browser controls and the CIIPanel host (a span
inside a block-layout .cii-country row) put the star on its own line.

Visual analogue: .cii-share-btn (transparent + 1px var(--border) +
var(--text-muted) text + var(--semantic-info) hover). Same border-radius
family, same micro-padding scale.

CSS variables used: --border, --border-strong, --text-muted,
--semantic-info. Reuses the existing @Keyframes spin.

Critical layout fix (CIIPanel): added position:relative to
.cii-country and absolute-positioned .cii-follow-btn-host at top:6px
left:6px. The :not(:empty) gate keeps the rule a no-op when the
feature flag is off (handle.html === ''), and the sibling-combinator
rule .cii-follow-btn-host:not(:empty) ~ .cii-header { padding-left:26px }
shifts header content right ONLY when the star is mounted — flag-off
rows render identically to today.

CDP + CountryIntelModal hosts are simple display:inline-flex since
both parent containers (.cdp-header-left, .country-intel-title) are
already flex with gap.

Polish (color tuning, hover transitions, animation curves, empty/cap
nudge styling) intentionally deferred to PR B per the third-pass review.

+156 lines (single appended block in src/styles/main.css).

* fix(followed-countries): serialize country counters

* fix(followed-countries): register picker global
koala73 added a commit that referenced this pull request Jul 24, 2026
* feat(activation): pure pro-activation state core — mount decision, step model, fire-once keying (U1)

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* feat(activation): interstitial shell — overlay, step chrome, focus trap, exit summary, en copy (U3)

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* feat(activation): brief/alerts/power step wiring + finish-setup chip (U4-U6)

Brief: atomic setNotificationConfig with explicit hour+IANA tz, insights world-brief preview, inline hour select. Alerts: pre-denied blocked state, patch-not-clobber channels, cadence-honest copy. Power: injected deep links + R8 settings pointer. Chip: versioned-key dismissal.

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* feat(activation): checkout-return marker + post-reload boot mount hook (U2)

Marker written before clearCheckoutAttempt on the success branch only; mount
decision evaluated off the boot critical path with bounded snapshot-retry.
Surfaces subscriptionId/currentPeriodStart on getSubscriptionForUser (additive,
existing columns) for the fire-once key — accepted plan deviation.

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* fix(activation): re-arm mount retry on subscription snapshot changes too

With the real subscription snapshot as the fire-once key input, a boot with
live entitlement but a not-yet-loaded subscription snapshot would stall in
'keep' forever watching entitlement only.

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* feat(activation): proActivation locale fan-out — 24 locales, register-calibrated (fa per its file convention)

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* feat(activation): funnel telemetry + end-to-end spec (U7)

Typed Umami events with whitelisted minimized payloads (planKey/step/exit
counts — never billing identifiers); single entered fire site at mount; 7
Playwright scenarios green against the dev server.

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* refactor(activation): simplify pass — shared focus-trap util, leaf record parsers, type reuse, idle-handle cleanup

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* fix(review): apply findings #1-4, #8-12, #14 — hour-capture ref, seeded alerts fallback, preview tri-state, coverage locks

P0 #1: digest hour captured in a closure ref so the in-flight re-render can't
wipe the user's pick. P1 #2: alerts catch-path seeds from flow context (+email
when brief confirmed) instead of empty — convex channels field is full-replace.
P1 #3 + P2 #8-12: failed-state e2e, chip assertion, expired-Pro branch, payload
combo, catalog-derived drift guard, focus-trap unit tests. P2 #4 tri-state
preview guard. P3 #14 unexported helper.

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* fix(review): apply findings #6, #7, #13 — account-scoped records, cross-tab mount claim

Marker/fire-once/chip records carry the Clerk userId; foreign-user markers
never mount (and are left for the buyer, TTL-reaped); unscoped markers are
bound to the first resolved session that observes them. Multi-tab mounts
serialize through a nonce claim with a 10s TTL. 92/92 unit, 8/8 e2e.

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* docs: refresh component/service counts for pro-activation modules (docs-stats gate)

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* fix(activation): address greptile review — preserve state on partial failure + product-id guard

- Brief digest cadence: buildBriefDigestPayload no longer forces 'daily' when an
  enabled weekly/twice_daily rule exists without a verified email channel; it
  omits cadence fields so the relay's field-guarded write preserves the schedule.
- Failed channel reads: readActivationContext now flags channelsKnown; confirm
  writes omit the channels field on an untrusted (failed) read so the relay
  preserves existing Telegram/Slack/etc. instead of replacing with an empty set.
- Fire-once ordering: persist fire-once + clear the marker only AFTER the
  interstitial opens; an import/init failure leaves the marker for a later retry
  (double-mount still prevented by the in-session latch + cross-tab claim).
- Product-id guard: derive PRO_PRODUCT_IDS from DODO_PRODUCTS (no raw pdt_
  literal); exclude the e2e test dir like tests/.

Addresses greptile P1 threads on #5534.

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* fix(activation): keep pro-activation leaf import-free; exclude it from product-id guard

Importing DODO_PRODUCTS into the leaf dragged the checkout-only product catalog
into the eager dashboard entry chunk (the leaf is statically imported by
panel-layout), failing the eager-chunk budget test. Revert to mirrored literals
kept in sync by the drift-guard test, and exclude the leaf from the raw-pdt_
guard instead (the drift-guard provides the catalog-sync the guard wants).

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* fix(activation): make onboarding account-safe
mitchross added a commit to mitchross/worldmonitor that referenced this pull request Jul 24, 2026
* docs(solutions): capture two reusable learnings from the KV cutover (#5338) (#5383)

* fix(mcp): make GET /mcp crawler-readable and fix the discovery cache key (#5382)

* fix(mcp): serve the human guide on GET /mcp and key discovery caches correctly

A plain GET to /mcp returned the transport's spec-correct 405, which Google
Search Console reports as "cannot access" — on www, on the apex, and on every
variant subdomain. It now returns the mcp-server.md guide as text/markdown,
and variant hosts 308 crawler GETs to the apex canonical.

The discovery response is where the real hazard is. /mcp and /.well-known/mcp
branch on Accept and Last-Event-ID, but the server card shipped
`public, max-age=3600` with no Vary. Vercel's edge keys on URL alone, so a
warmed discovery 200 was served back (x-vercel-cache: HIT) to a GET carrying
`Accept: text/event-stream` — handing an MCP SDK client a JSON body where the
transport contract requires 405. Reproduced on production against
/.well-known/mcp before this change; that is #4937's hang class arriving
through the CDN instead of the handler.

The card keeps its cacheability and gains Vary: Accept, Last-Event-ID. The
transport URL goes further and stays no-store, so its correctness never
depends on an intermediary honoring Vary. The variant 308 is built by hand
rather than via Response.redirect() so it can carry Vary too — a 308 is
cacheable by default (RFC 9110 15.4.9).

Canonical stays apex per ARCHITECTURE.md:72 — /mcp is on the Cloudflare
apex→www exemption list and the server card advertises the apex endpoint.
POST and OPTIONS are never redirected (#4938).

mcp-live-smoke.mjs gains the probes that can actually see this: warm the cache
with a plain GET, then fail if the SSE GET is anything but 405. Run against
un-fixed production it reproduced all three defects independently.

Claude-Session: https://claude.ai/code/session_01HHHP2TMJZUAYuzp6iGvYHD

* docs(solutions): MCP crawler GET and the CDN discovery-cache replay

Records the finding behind the /mcp fix: a cacheable discovery 200 on a URL
that content-negotiates on request headers gets replayed by a URL-keyed edge
cache to transport clients. Includes the production reproduction, the
Vary-vs-no-store reasoning, and the landmine that the first version of the
regression check (/\bAccept\b/i) passed against the un-fixed origin because
`-` is a word boundary and it matched `accept-encoding`.

Adds the Discovery Read vs. Transport Operation concept to CONCEPTS.md.

Claude-Session: https://claude.ai/code/session_01HHHP2TMJZUAYuzp6iGvYHD

* fix(mcp): harden discovery negotiation

* fix(review): preserve MCP discovery contracts

* fix(mcp): align HEAD discovery metadata

* fix(deps): clear high-severity DoS advisories failing the security-audit gate (#5395)

* fix(analytics): swallow Umami beacon rejection leaking to Sentry (#5393)

* chore(lint): appease biome 2.4.9's promoted rules across scripts/ (unblocks all PRs) (#5400)

PR #5395's lockfile refresh floated biome 2.4.7→2.4.9 inside the caret
range; 2.4.9 promotes rules (useIndexOf, noAdjacentSpacesInRegex,
noUselessContinue, noUselessStringRaw, useDefaultParameterLast,
noUnusedFunctionParameters) that flag 20 pre-existing spots in scripts/.
Main's path-filtered biome job hasn't re-linted scripts/ since, so every
PR triggering a full lint now fails (first: #5397).

All fixes are behavior-neutral: indexOf/regex/String.raw rewrites are
equivalence-preserving, unused params dropped, and selectTopStories
keeps its maxCount=8 default under an explicit biome-ignore (the
auto-fix would have silently changed the signature contract).

* test(rss-proxy): wire test file into CI, lock the SSRF/auth/rate-limit guards, fix relay www-tolerance (#5378) (#5399)

* test(rss-proxy): wire test file into CI and lock the pre-fetch guards (#5378)

api/rss-proxy.test.mjs was in neither test:data nor test:sidecar, so its 5
tests never ran in CI. Add it to test:sidecar and close the adversary-reachable
coverage gaps the sweep found (5 -> 28 tests).

The sweep flagged "SSRF hostname/userinfo confusion" as Critical. Probing the
real predicate shows it is NOT a bypass: WHATWG new URL() strips userinfo into
username/password, and isAllowedDomain only strips a leading "www." — so
techcrunch.com.attacker.example, [email protected] and the
trailing-dot FQDN form all resolve to a non-allowlisted hostname and 403. The
real finding is that the guard had zero coverage; deleting it left the suite
green while the handler fetched the attacker host. These tests close that.

New coverage, each mutation-proven (14 mutations, every one killing exactly its
target test and no others):
- initial-host allowlist: suffix/userinfo/trailing-dot confusion + link-local
  metadata, asserting the attacker host is never fetched
- auth: missing key -> 401, invalid key -> 401, both before any upstream call
- rate limit: exhausted -> 429 with no feed fetch, plus the headroom case so
  the 429 is attributable to the limiter verdict, not to Upstash being set
- protocol: file:// -> 400 (not the 403 domain verdict)
- request shape: missing/malformed url -> 400, OPTIONS -> 204, non-GET -> 405,
  disallowed Origin -> 403 without echoing the attacker origin
- response policy: relay-only routing + long cache TTLs, short TTLs on success,
  no CDN-Cache-Control on a failed upstream, non-2xx relay retry, content-type
  fallback, AbortError -> 504, and the Google News 20s vs default 12s deadline

Drift fix surfaced by the new invariant test: RELAY_ONLY_DOMAINS still listed
www.arabnews.com, which #1626 removed from the RSS allowlist as a dead feed
domain. The allowlist runs first, so the entry could only ever 403 before the
relay routing it exists for was consulted. Arab News is sourced via
news.google.com, not a direct arabnews.com feed.

RELAY_ONLY_DOMAINS is exposed via the repo's `export const __testing__ = {...}`
test-only convention (matching api/health.js, api/bootstrap.js, api/mcp.ts) so
the test can assert every relay-only host is also allowlisted, preventing the
same drift from recurring — without widening the module's public surface.

Claude-Session: https://claude.ai/code/session_018RkPSPXZKPpBtvAhNZx3au

* fix(rss-proxy): www-tolerant relay routing + collapse the drifted dev allowlist (#5378)

Two fixes surfaced by the #5378 review, both approved for this PR.

1. Relay-only routing www/apex asymmetry (was: exact-match). `isAllowedDomain`
   is www-tolerant (strips/adds a leading `www.`) but the relay-only check was
   `RELAY_ONLY_DOMAINS.has(hostname)` — exact. Result: all 17 relay-only hosts
   were reachable via their alternate form (e.g. `cisa.gov` for the registered
   `www.cisa.gov`), which passes the allowlist but misses relay routing and
   gets direct-fetched from a Vercel edge IP these hosts block — paying the full
   12s/20s timeout before the error fallback recovers. Dormant today (every
   registered feed uses the exact form), but structural. Fix: extract
   `hostMatchForms()` into api/_rss-allowed-domain-match.js and use it for BOTH
   the allowlist predicate and the relay-only check, so the invariant is
   structural rather than dependent on data-entry symmetry. New test
   (apex `cisa.gov` routes to the relay) is mutation-proven: reverting to
   `.has(hostname)` turns it red.

2. vite.config.ts held a THIRD copy of the RSS allowlist for the dev-server
   proxy that had drifted ~138 domains from prod (134 prod hosts 403'd in dev;
   4 dev-only entries including the dead `www.arabnews.com`), while
   scripts/validate-rss-feeds.mjs claimed it was a kept-in-sync mirror. Replace
   the hand-maintained Set with a direct import of `isAllowedDomain` so dev and
   prod share one www-tolerant allowlist. Validator's mirror list drops 5 -> 4.
   Verified `vite build` succeeds with the edge-module import.

Claude-Session: https://claude.ai/code/session_018RkPSPXZKPpBtvAhNZx3au

* test(rss-proxy): add negative-space + failure-branch coverage from review (#5378)

Multi-lens review (security, correctness, adversarial, testing) confirmed the
SSRF false-positive call and the [-1, 600] Upstash mock shape, but found the
guard tests only rejected LOOKALIKES of allowlisted names, never a plain
stranger — plus a few weak spots. 29 -> 33 tests, each new one mutation-proven.

- Plain non-allowlisted stranger host rejected on BOTH the initial-host and the
  redirect-hop allowlist. Every prior negative case was a lookalike
  (techcrunch.com.attacker.example, [email protected], trailing-dot) or a raw
  IP, so loosening the guard to `!isAllowedDomain(h) && !h.endsWith('.com')`
  (admit any .com) stayed green. Now killed on both code paths.
- Generic 502 'Failed to fetch feed' branch + its lone captureSilentError call
  site — previously untested — covered both reachable ways: a non-Abort
  direct-fetch throw with no relay, and a relay-only host with no relay.
- Rate-limit headroom positive control given teeth: it returned 200 whether the
  limiter granted headroom OR threw and failed open. Now asserts the
  `[rate-limit] redis-error` degraded log never fired (proven: a garbage Upstash
  reply now fails the test instead of passing).
- Google News deadline test's unbounded `while (!signal)` spin bounded + given a
  per-test timeout: a regression that stops the handler reaching fetch now fails
  in ~110ms with "handler never reached fetch" instead of hanging the CI runner
  forever (verified).
- Guard-ordering test now fails all three early gates at once (bad Origin + no
  key + non-GET) so only ordering explains the 403 'Origin not allowed'.
- CORS Allow-Methods pinned to exact 'GET, OPTIONS' (was substring /GET/); 429
  now asserts it still carries CORS headers. Both mutation-proven.
- AbortError test comment corrected to state the Sentry-suppression gate is NOT
  asserted (captureSilentError no-ops under NODE_TEST_CONTEXT), instead of
  implying coverage it lacks.

Claude-Session: https://claude.ai/code/session_018RkPSPXZKPpBtvAhNZx3au

* test(pro): critical-path budget guard for the committed /pro build (#5396) (#5397)

* test(pro): critical-path budget guard for the committed /pro build (#5396)

Any PR that rebuilds the pro app (a #5374-class change) can silently
regress the page's critical path; the only tripwire was the weekly
DebugBear email, days later and averaged. This guard runs at PR time
against the committed artifacts: 700KB critical-path budget (entry +
modulepreloads + stylesheets; currently ~625KB), Clerk must never be
referenced from the page HTML and must stay a dynamic import in the
entry chunk (the 3MB parse behind the lab score of 63), and a 6MB
whole-assets cap. Checkers are pure and teeth-tested against bad
fixtures so the guard itself is proven able to fail.

* fix(test): drop exports from the pro budget guard — biome noExportsInTest (error severity in 2.4.9)

* fix(seed-research): isolate arXiv categories + retry + TTL so a single blip can't empty prod (#5409) (#5413)

* fix(deps): clear fresh high advisories redding audit-lockfile on all PRs (#5417) (#5418)

* feat(content): 13 blog posts — undocumented features (tenders, sanctions, aviation, radiation, energy dashboard…) + 'not Palantir' positioning (#5403)

* fix(pricing): call out 'License / API key included' on the API Starter tier (#5419)

* fix(rss-proxy): remove dead rsshub.app from allowlists (#5414)

* fix(deps): clear sharp + fast-xml-parser high advisories redding audit-lockfile on all PRs (#5423) (#5424)

* fix(feed-digest): decode &amp; last so RSS entities aren't decoded twice (#5432)

* fix(feed-digest): decode &amp; last so RSS entities aren't decoded twice

`decodeXmlEntities` decoded `&amp;` first. That turns the escaped ampersand of
`&amp;lt;` into a live `&`, which the very next `.replace` immediately consumes
as `&lt;` — so a single pass decodes two levels. `&amp;` has to go last.

The visible damage differs by call site:

- `extractTag` (titles): a headline whose literal text is `&lt;script&gt;`
  arrives escaped as `&amp;lt;script&amp;gt;` and comes back as `<script>` —
  raw markup injected into `ParsedItem.title` and emitted on the wire instead of
  the text the publisher wrote.
- `extractDescription`: it strips tags *after* decoding, so the same input loses
  the words entirely — "XSS triggered by &lt;script&gt; tags in profile bios"
  becomes "XSS triggered by  tags in profile bios".

Also switch the numeric-reference branches from `String.fromCharCode` to
`String.fromCodePoint`. `fromCharCode` truncates to 16 bits, so `&#128512;`
decoded to U+F600 (private use) instead of 😀. Out-of-range values are dropped
rather than allowed to throw `RangeError`, which would fail the whole feed parse
over one malformed reference.

Adds `tests/news-feed-digest-entity-decode.test.mts`, including a
produce-then-consume round-trip (escape a plain string, decode it, assert the
original comes back). 5 of its 6 cases fail on the previous implementation.

* test(feed-digest): cover the &apos; branch in the round-trip helper

Review follow-up. The local escapeXml helper didn't escape apostrophes, so the
round-trip assertion never produced '&apos;' and never exercised that decode
branch. Escapes it now, and adds an original containing apostrophes — escaping
alone would not have reached the branch, since no existing case contained one.

This case is coverage, not a second regression case: it round-trips on the old
implementation too. The double-decode cases above it are what fail on main.

---------

Co-authored-by: thejesh23 <[email protected]>
Co-authored-by: Elie Habib <[email protected]>

* fix(seed-utils): retry transient Redis blips on the read path + skip-path meta ops (#5437) (#5438)

The gdelt-intel cache-merge fallback loads the previous canonical snapshot
via verifySeedKey -> redisGet: 5s abort, no retry, and any non-OK status
silently returned null. During the 2026-07-21 GDELT brownout one blip per
run at the soft-budget boundary read as "no previous snapshot" - the merge
no-op'd, validation failed, runs skipped without writing, and seed-meta
aged 21h until the freshness gate fired, while the canonical key was
perfectly healthy. Sibling unretried ops on the same skip path produced
two exit-1 FATAL crashes (writeFreshnessMetadata SET abort).

- redisGet: withRetry with the redisCommand tagging contract (permanent
  4xx fail fast, 429 honors Retry-After, timeout/5xx backoff). External
  contract unchanged: HTTP failures still degrade to null (now loudly),
  thrown failures still propagate - both only after retries.
- writeFreshnessMetadata: wrap the SET in withRetry so a single Upstash
  abort can't escape as FATAL on the validate-skip path.
- readCanonicalEnvelopeMeta: retry before degrading - a blip here writes
  recordCount=0 with fetchedAt=NOW, resetting the freshness clock over
  real staleness.
- seed-gdelt-intel _loadPrevious: replace the silent catch with a loud
  cache-merge warning so a dead fallback is visible in run logs.

Tests: 13 new (red-first) covering retry/degrade/propagate contracts for
all three helpers plus the seeder's default wiring; seeder suite 794 green.

Closes #5437

Claude-Session: https://claude.ai/code/session_01AKbRZXV6kKe8MTYpKZSLBM

* Update blog post to 6 dashboards, fix variants count, and add Energy … (#5408)

* Update blog post to 6 dashboards, fix variants count, and add Energy Atlas section

* Address PR review: fix panel count to 26, update stale 'five' reference, fix heading capitalization, and add trailing newline

* review fixes: registry-true panel counts, energy deep-dive link, modifiedDate

Panel counts refreshed against src/config/panels.ts (102/41/60/32/10/26 —
four of the old numbers had drifted). Link the new energy post shipped in
#5403, pin pipeline claim to the 88 mapped in code, fix "All Six" casing,
add modifiedDate + energy keyword.

Claude-Session: https://claude.ai/code/session_01JEGono85MnwW7F9mm4rQEF

---------

Co-authored-by: Elie Habib <[email protected]>

* feat(content): receipts round — rewrite Palantir post, print the real scorecard, live radiation + tender records (#5443)

* docs(solutions): three-layer cache eviction runbook for the live product catalog (#5422)

* feat(blog): pinned-post support; pin the Palantir post to the top of the index (#5446)

* fix(content): replace blog title cards with product imagery (#5452)

* feat(agent-readiness): sandbox, docs-MCP JSON-RPC errors, schemamap, modular llms.txt (orank round) (#5469)

* feat(agent-readiness): advertise SDKs in the agent view + homepage rel=alternate pointer (orank round 2) (#5476)

* docs(blog): clarify WorldMonitor and Palantir positioning (#5480)

* fix(payments): re-check Dodo before stale-subscription denial (#5447)

* feat(blog): strengthen SEO and AI discoverability (#5475)

* fix(payments): distinguish transient entitlement-lookup failure from confirmed denial (#5483)

* ci(deploy-gate): retry the check-runs poll before posting a terminal 'pending' (#5479) (#5482)

* fix(seed-gdelt-intel): degrade bookkeeping failures instead of crashing; brownout-scale timeline TTL; content-age opt-in (#5478) (#5481)

* fix(auth): honor current API access during renewal checks (#5490)

* feat(homepage): link Palantir positioning article (#5491)

* ux(payments): billing-aware renewal/lapsed states instead of generic Upgrade CTA (#4771) (#5494)

* feat(billing): pure billing UX state derivation for #4771

* feat(billing): expose renewalVerificationState from getSubscriptionForUser

* feat(billing): billing-aware panel gating copy instead of generic Upgrade CTA

* feat(billing): renewal-verification banner variants; gating follows subscription changes

* fix(widget-agent): structured billing-verification denial before generic 403

* refactor(billing): localize banner via shared i18n keys; per-pass gating derivation; skip no-op CTA rebuilds

* fix(review): cancelled-in-period coverage, billing-denial on-call log, behavioral + wiring test locks

* docs: bump service-module count for billing-state.ts

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* docs: regenerate stats.json for billing-state service module

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* docs(solutions): compound #4771 learnings — coverage-semantics bug + i18n shell convention (#5495)

Two learnings from PR #5494 plus a Billing & Entitlements vocabulary seed
and an English Shell glossary entry in CONCEPTS.md. Claims grounding-validated
against source and live GitHub state (28 claims, 2 corrected).

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* fix(notification-channels): bound convexRelay() with a 15s timeout (#5485)

* fix(mcp): route sibling fetches through canonical API (#5517)

* fix(python-sdk): avoid secret-scan literals (#5486)

* fix: avoid Python SDK secret-scan literals

* test(python-sdk): pin auth header contract

---------

Co-authored-by: Elie Habib <[email protected]>

* fix(auth): keep Pro brief denials out of session recovery (#5516)

* fix(auth): keep Pro brief denials out of session recovery

* Address PR review feedback (#5516)

- Preserve successful premiumFetch route matches in the AST guard

* fix(ci): update pro-test PostCSS lockfile

Upgrade PostCSS past GHSA-6g55-p6wh-862q and refresh its Nanoid dependency.

* fix(auth): close the #5379 adversarial sweep — resource exhaustion, state corruption, MCP entitlement gaps, and the inert live suite (#5385)

* fix(auth): bound the Clerk plan lookup with AbortSignal.timeout (#5379)

server/auth-session.ts lookupPlanFromClerk() fetched api.clerk.com with no
timeout. validateBearerToken awaits it on every standard (non-template)
session token — the ones without a `plan` claim — so a stalled Clerk let an
authenticated caller pin gateway invocations open indefinitely.

Adds signal: AbortSignal.timeout(3s), matching the budget already used by the
other external auth lookups (server/_shared/user-api-key.ts and
api/_user-api-key.js VALIDATION_TIMEOUT_MS). The existing catch already
fail-softs, so a timeout degrades to 'free' exactly like an HTTP error.

Regression test drives the real seam (signed RS256 JWT against a local JWKS
server -> validateBearerToken -> lookupPlanFromClerk) with a never-settling
Clerk stub, and additionally pins that a timed-out lookup does NOT poison the
5-minute plan cache with a 'free' verdict — the next request retries.

Mutation-proved: removing the signal line turns the test red
(actual: 'still-pending').

* fix(auth): validate user-API-key shape and canonical format before trusting it (#5379)

Two gaps in server/_shared/user-api-key.ts, both on the live gateway auth path.

1. State corruption / EoP. cachedFetchJson<UserKeyResult> only CASTS its
   payload — a poisoned cache entry or upstream shape drift (e.g. {}) reached
   callers as a truthy 'authenticated principal' whose .userId read undefined.
   Adds isUserKeyResult(), an own-property runtime guard (hasOwnProperty, so a
   polluted Object.prototype.userId cannot authenticate a bare {}). null still
   passes through untouched as the legitimate negative-cache answer. The warn
   logs the type only — payload and key hash are credential material.

2. Malformed-key amplification. startsWith('wm_') let wm_x burn a SHA-256, a
   Redis round-trip and a Convex lookup per attempt. Now gated on
   /^wm_[a-f0-9]{40}$/ BEFORE hashing, matching the sibling api/_user-api-key.js.
   The regex is deliberately duplicated rather than imported (that module
   evaluates env at load and pulls redisPipeline + client-ip into the edge
   bundle for one regex); a test asserts the two literals stay byte-identical
   so drift fails CI.

Also corrects the UserKeyResult interface, which was lying: it declared
keyId/name required, but fetchFromConvex returns Convex's row verbatim
({id,userId,name}) so keyId is undefined on that path — only
api/_user-api-key.js maps id->keyId. Requiring keyId in the guard would have
401'd every fresh Convex validation. Verified all three callers
(server/gateway.ts, server/_shared/premium-check.ts, api/mcp/auth.ts) read
ONLY .userId; api/mcp/types.ts already types the dep as {userId: string}|null.

Mutation-proved: reverting the format guard reds 9 tests (including the
'backend never invoked' amplification assertions); neutering the shape guard
reds 10. 28/28 green restored.

* test(mcp): give the entitlement gate and both rate limiters teeth (#5379)

Gaps 4, 9 and 10. api/mcp/auth.ts is unchanged — this is coverage only.

Gap 4: checkMcpEntitlementGate enforces tier>=1, mcpAccess===true and
validUntil>=now, but every existing test used ONE fixture violating all three
at once, so any single predicate could be deleted with 144/144 still green.
Replaced with a one-predicate-at-a-time matrix (each case violates exactly one
predicate and satisfies the rest), plus strict-truthiness cases (mcpAccess:
'true' and 1 must still 401) and a getEntitlements-throws case. Covers both
paths that reach the gate — pro and user_key — since user_key is the
credential class that would otherwise silently skip it (#4859).

Mutation-proved, re-verified independently by the orchestrator:
  drop tier<1        -> 4 tests red
  drop !mcpAccess    -> 8 tests red
  drop validUntil<now-> 4 tests red
  drop !ent          -> SURVIVES, and cannot be killed: the following lines
                        read ent?.features?.tier ?? 0 etc., so a null ent
                        already collapses to tier=0 and is rejected by
                        tier<1. !ent is redundant defence-in-depth and is an
                        equivalent mutant by construction — documented here
                        rather than papered over with a test that fakes it.

Gaps 9/10: applyPerMinuteLimit and applyAnonDiscoveryLimit were never
exercised. Now pinned for all three limiters: absent limiter -> pass-through,
under/over limit, the -32029 message text, emitMcpRateLimitHit payload, and
the deliberate fail-OPEN on limiter throw. Bucket keys are asserted
explicitly — key:<apiKey> for env_key, and pro AND user_key both mapping to
pro-user:<userId>, which is the security-relevant claim that the two share one
60/min budget rather than stacking two.

Anon discovery additionally pins the client-IP trust model: a spoofed
x-forwarded-for cannot rotate the bucket, cf-connecting-ip is only trusted
with matching CF_EDGE_PROOF_SECRET, and a missing IP falls back to a shared
bucket rather than an empty key.

73 tests, all green.

* test(auth): mutation-proof the bootstrap user-key cache, coalescing and timeout (#5379)

Gaps 6, 7 and 8. api/_user-api-key.js is unchanged — this is coverage only.

Gap 6: the cache-hit guard (truthy && object && typeof userId==='string' &&
userId.length>0) was correct but untested, so any conjunct could be deleted
silently. The issue filed this as 'empty cached userId accepted'; that framing
is wrong — the guard already rejects it. Each conjunct is now individually
load-bearing: '' / 123 / null userId, and non-object hits (string, array,
number, null) must all decline to trust the cache entry.

Gap 7: request coalescing collapses N concurrent lookups of the same key hash
onto ONE Convex round-trip. Deleting the coalesce() wrapper broke nothing, so
a burst with one key could amplify 1:1 onto Convex. Now asserted three ways —
5 concurrent calls with the same key hit the backend exactly once and all
resolve identically; 5 concurrent calls with DIFFERENT keys hit it 5 times
(proving we measure coalescing, not caching); and a call after the first
settles hits the backend again, proving the in-flight map's finally-delete
cleanup does not leak entries.

Gap 8: postConvexJson's AbortSignal.timeout(VALIDATION_TIMEOUT_MS) was
unasserted, so its deletion would have reintroduced an unbounded auth fetch.

Mutation-proved, re-verified independently by the orchestrator — each of the
three deletions reds exactly one test:
  drop .length>0                 -> 1 red
  drop coalesce() wrapper        -> 1 red
  drop signal: AbortSignal...    -> 1 red

38 tests, all green.

* ci(auth): actually run the live cache/auth sweep, and fix the stale assertion it was hiding (#5379)

Gap 5. tests/live-api-cache-auth-regression.test.mjs is wrapped in
describe(..., { skip: !LIVE }) gated on LIVE_API_CACHE_TESTS=1, which nothing
in the repo ever set — verified by grep across .github/, package.json and
scripts/. The file is in the test:data glob, so every CI run 'passed' it while
executing zero assertions; the issue's mutation proof (unconditional throw at
the top of the describe) still exited 0. Confirmed locally: without the flag
the runner reports 'tests 0, pass 0'.

Adds a scheduled workflow modelled on the existing mcp-live-smoke.yml
precedent — cron every 6h at :47 (offset from that job's :23 so two live
probes don't hit prod from the same runner range in the same minute),
push-to-main filtered to the suite + workflow, and workflow_dispatch. Not
pull_request: the target is live production, so a PR run could neither
exercise its own changes nor fail for reasons the PR caused. No npm ci — the
suite imports only node:assert and node:test. No secrets provisioned; the one
authenticated probe stays per-test gated on WM_LIVE_TEST_KEY and reports SKIP,
not failure.

Turning it on immediately surfaced a stale assertion, which is the whole point:
the suite required mimeType 'application/json' for EVERY resources/list entry,
with a comment asserting 'the catalog is now all concrete, metadata-only
resources'. That stopped being true when the MCP-Apps ui:// fleet landed —
production correctly serves ui://worldmonitor/country-risk.html as
'text/html;profile=mcp-app' (api/mcp/ui/shell.ts UI_RESOURCE_MIME_TYPE).
Production is right; the never-executed test had rotted.

Fixed by mirroring the rule the in-process sibling already uses
(tests/mcp-resources.test.mjs:383): expected mimeType is chosen by URI scheme,
so it remains an exact-match assertion — a resource declaring the WRONG one of
the two still fails — and the payload is now verified to parse as what it
declares (HTML doctype vs JSON.parse) rather than assuming JSON.

Verified against production: 6 pass, 0 fail, 1 skip (the WM_LIVE_TEST_KEY
case). Without the env var: still a clean 0-test skip.

* test(security): catch identifier-vs-identifier secret comparisons (#5379)

Gap 11. The #3803 timing-oracle guard's regex required the right operand to be
process.env.* or a token starting with a bare `expected`/`EXPECTED`. Because
`expected\b` has no word boundary inside `expectedSecret`, the guard sailed
straight past the most natural way to write the bug:

    probeSecret !== expectedSecret

The left arm had the mirror hole: `\b(?:secret)\b` never matched inside
`probeSecret`, so even `probeSecret !== process.env.RELAY_SHARED_SECRET` leaked.

Now matches a secret-bearing identifier compared against process.env.*, an
expected* constant, or ANOTHER secret-bearing identifier, in either order, and
supports member chains (`req.headers.token === expectedToken`). SECRET_VARS
collapses to ['secret','token','bearer'] since matching moved from whole-word
to substring — the compound entries are subsumed, and a generic 'key' is still
excluded so cacheKey/sortKey don't drown the guard in noise.

Structural change: the pattern and the comment-stripping step are extracted
into exported helpers so the meta-test exercises the EXACT regex the real scan
uses. The previous meta-test reconstructed a copy-pasted duplicate, so it could
pass while the real scan diverged — a guard-testing-a-guard that proved nothing.

The table is now the regex's contract, with must-NOT-match rows carrying equal
weight: the correct timingSafeEqual idiom, presence/nullish checks, and type
checks (typeof token === 'string') must never flag. Seven rows are REAL lines
lifted from the api/ tree with file:line attribution (api/oauth/token.ts:725
grantType === 'refresh_token', api/notification-channels.ts:239, and the
_mcp-grant-hmac token.length index compare) so the negative cases are grounded
in code that actually exists rather than hypotheticals. False positives get
guards deleted, which would return us to the original hole.

Quoted literals are unreachable by construction: the operator is bracketed by
\s*, never .*, so the matcher cannot step over a quote to reach a fragment
inside a string. ALLOWLIST_FILES stays empty — the real api/ scan is green.

Mutation-proved: dropping the ident-vs-ident arm reds the meta test. 3/3 green.

* docs(auth): pin the entitlement-null fail-open posture where the code lives (#5379)

The 'design risk' from the issue. Decision: KEEP fail-open. No behavior change —
server/gateway.ts and entitlement-check.ts changes are comments only.

The posture was previously articulated ONLY inside a test file, so a reader of
server/gateway.ts saw a null-entitlement path serving 200 with no sign it was
deliberate. The decision site now documents the posture, the blast-radius
reasoning (fail-closed turns any Convex/Upstash blip into a fleet-wide 403 for
every paying API customer at once), and what bounds the leak. Verified each
bound rather than asserting it:
  - Not reachable by the never-subscribed: minting a wm_ key ITSELF requires an
    active entitlement with apiAccess (convex/apiKeys.ts throws
    API_ACCESS_REQUIRED otherwise), so no entitlement row ⇒ no key ⇒ this path
    is never entered.
  - Tier-gated routes do NOT inherit it: checkEntitlement fail-CLOSES on null.
  - Warm path re-resolves within the 15-min cache, so a lapsed user must
    sustain an outage rather than wait one out.

entitlement-check.ts's docstring and catch comment claimed 'fail-closed …
caller blocks the request', which is false for this caller and invited someone
to 'fix' the fail-open as a bug. Both now state that null means UNRESOLVED and
that the conclusion is caller-dependent, naming both callers.

Also documents a MISCONFIGURATION HAZARD the original framing missed: a deploy
missing CONVEX_SITE_URL or CONVEX_SERVER_SHARED_SECRET returns null for every
user on every request, permanently. For the fail-open caller that is NOT a
transient blip the cache heals — there is no warm path to recover to, so the
gate is silently disabled indefinitely. Marked P1, not a degraded mode.

Tests go 10 -> 19, all 10 originals preserved. New cases pin each boundary at
its ACTUAL behavior, not an assumed one: null and undefined serve; {features:{}}
and apiAccess:false 403 (a resolved-but-empty row fails CLOSED, so the hole is
narrower than 'any malformed object'); {} and a throwing getEntitlements
propagate rather than serve; and the warm path 403s once a downgrade resolves.

Records one KNOWN GAP as a test rather than hiding it: an entitlement with
apiAccess:true and a MISSING validUntil is served with expiry unchecked, since
`undefined < Date.now()` is false. Not currently reachable — convex/schema.ts
declares validUntil: v.number() as required — so this is latent robustness on
the cache path, not a live hole. Filed for follow-up.

Mutation-proved: flipping the null case to fail-closed reds 4 tests. 19/19 green.

* test(auth): pin the Convex-misconfig null path that the fail-open bound assumes away (#5379)

The gateway fail-open comment bounds its risk on 'the warm path re-resolves'.
That premise assumes the entitlement EVENTUALLY resolves. A deploy missing
CONVEX_SITE_URL or CONVEX_SERVER_SHARED_SECRET takes the early return, so every
user resolves to null on every request with no self-healing path — the 15-min
cache cannot warm what never resolves, silently disabling the #4611 apiAccess
gate fleet-wide and indefinitely.

Pinned here so the premise of that bound stays honest. Asserts repeated nulls
across distinct userIds (not one coalesced in-flight promise) and a same-user
retry (no recovery).

Env is saved and restored in a finally, matching this file's existing
convention — deleting without restoring would leak the broken env into any test
appended after this one, which is the same module-state-leak class PR #5370 had
to clean up.

20/20 green.

* test(auth): make wm_ key fixtures canonical so they match what production can mint (#5379)

Fallout from the validateUserApiKey format tightening two commits back, and a
gap in my own blast-radius check: I cleared the consumers by grepping static
imports, but server/gateway.ts reaches the validator through a DYNAMIC
`await import('./_shared/user-api-key')`, so these suites exercised the real
implementation rather than a mock and a static-import grep could not see it.

Six tests across two files passed keys like 'wm_free_test_key' and
'wm_test_active_key' — readable placeholders that generateKey()
(src/services/api-keys.ts) can never produce, since it always mints wm_ + 40
lowercase hex. They were asserting gateway behavior on an input production
cannot generate.

Replaced with canonical well-shaped fixtures behind named constants so the
role stays legible at each call site. No assertion changed: the same
x-user-id rewriting, entitlement gating and plan_key telemetry are still
asserted, now with a realistic key. The Convex mocks in both files match on
URL, not on the key or its hash, so the values are arbitrary provided they are
well-shaped.

This raises fidelity rather than accommodating the new guard — the old
fixtures would have sailed past a format check that production has always
effectively had at the Convex layer.

tests/*.test.mts: 4352/4352 green (was 4346 pass / 6 fail).

* fix(review): close the three P1s the review found in this PR's own work (#5379)

Multi-persona review, including an independent cross-model adversarial pass
(gpt-5.5 via Codex). Three P1s, two of them defects this PR introduced.

1. MISSING EXPIRY SERVED FOREVER (server/gateway.ts) — found independently by
   the security reviewer AND the cross-model pass, both P1/100, citing the same
   line. An entitlement with apiAccess:true and NO validUntil was SERVED:
   `undefined < Date.now()` is false, so the expiry arm silently no-opped. Worse
   than the documented null posture, which at least self-heals — this one
   re-resolves to the same shape every request, so a lapsed subscriber keeps the
   keyed surface permanently. The sibling MCP gate already got this right
   (`ent?.validUntil ?? 0`); the gateway now matches. The earlier commit shipped
   this as a pinned 'KNOWN GAP' test; that was the wrong call when the fix is one
   nullish-coalesce, so the test is flipped to assert 403 and a second test pins
   the narrower residual (a non-numeric validUntil still serves — the real fix
   there is runtime shape validation in getEntitlements, noted not hand-waved).

2. THE NEW CI GATE COULD PASS ON ZERO TESTS (.github/workflows/live-api-cache-auth.yml)
   — flagged by three independent reviewers. Setting LIVE_API_CACHE_TESTS was not
   enough: the suite is one `describe(..., { skip: !LIVE })` and `node --test`
   exits 0 when everything skips, so a rename or typo would have recreated the
   exact silent-green bug this workflow was written to fix. The suite's own
   `assert.equal(LIVE, true)` self-check cannot help — it is inside the skipped
   describe. The step now pins `--test-reporter=tap` (not the default, which Node
   selects by TTY-ness) and fails unless `# pass N` shows N>=1. Verified both
   ways: env var absent -> exit 1 with an actionable ::error::; present -> exit 0,
   6 passing.

3. THE #3803 TIMING-ORACLE GUARD WAS PASSING VACUOUSLY
   (tests/no-non-timing-safe-secret-compare.test.mts). `stripComments` deleted
   30.2% of api/ by bytes before the scan — measured and reproduced: a glob like
   `/*.openapi.json` inside a // comment reads as a block-comment OPENER and ate
   4160 bytes of api/mcp/types.ts including `export interface RpcToolDef`, and //
   inside a URL literal truncated real lines. A violation in a swallowed region
   was invisible. The stripper is gone (raw scan finds zero false positives across
   all 174 files), and a new test plants a known violation into EVERY api/ file
   and requires the scan to flag every one — so any future normalisation that eats
   real code turns red instead of quietly passing.

Also from the review:
- Widened the same guard to two shapes it missed: a neutrally-named local vs a
  secret-NAMED env var (neither operand is secret-named, so every ident arm
  missed it), and an inline `headers.get('x-...-secret') !== expected` — the
  codebase's dominant header idiom, and the literal shape of #3803 itself.
  Negative rows pin that non-secret env vars and non-secret header keys stay
  unflagged.
- server/auth-session.ts: added the AGENTS.md-mandated User-Agent to the Clerk
  fetch. Every other outbound server fetch sets one; this was the sole exception.
- Replaced every cross-file numeric line citation in the new posture comments with
  symbol references. They were already wrong ON ARRIVAL — this PR's own added
  lines shifted them, so entitlement-check.ts:289 pointed at a function parameter
  and :229-233 at an unrelated Redis comment. A comment whose value is being
  checkable must not rot on commit.

Verified: typecheck + typecheck:api clean; vitest 816/816; tests/*.test.mts
4353/4353; tests/*.test.mjs + cli 11719 pass / 0 fail; sidecar 230/230.
Mutation-proved: removing `?? 0` reds the closed-gap test.

* fix(review): restore silently-gutted coverage and make two failure modes observable (#5379)

Second review round, from the reliability and testing reviewers.

1. SILENTLY GUTTED TEST (tests/mcp-proxy.test.mjs). Fallout from this PR's key
   format tightening, in a file my earlier blast-radius sweep missed precisely
   BECAUSE it kept passing. The test "rejects wm_ user keys when Convex
   validation cannot run" used the placeholder 'wm_user_abc123'; since the
   tightening that key is rejected at the format gate before hashing, so the
   test still returned 401 while covering none of the path its own comment
   claims to prove - including the MODULE_NOT_FOUND dynamic-import regression it
   was written to catch. A green test that stopped testing anything is the exact
   failure class this PR exists to fix. Now uses a canonically-shaped but
   never-minted key so the request reaches fetchFromConvex and is rejected there.

2. MY OWN COMMENT WAS FACTUALLY WRONG (server/_shared/entitlement-check.ts). The
   MISCONFIGURATION HAZARD note added earlier in this PR claims the state is
   "surfaced" by the one-time console.warn in getConvexSharedSecret(). That warn
   only fires when the SHARED SECRET is missing - a deploy missing only
   CONVEX_SITE_URL disabled the Convex fallback, and therefore the fail-OPEN
   #4611 apiAccess gate, with no signal whatsoever. Rather than weaken the
   comment to match the code, added getConvexSiteUrl() with its own one-time warn
   so the claim is true. One warn per variable is deliberate: warning on only one
   of the two is what created this hole.

3. SILENT PRO->FREE DEGRADATION (server/auth-session.ts). lookupPlanFromClerk's
   catch swallowed everything and returned 'free' with no logging on any path.
   The AbortSignal.timeout added earlier in this PR made that path newly
   reachable from an ordinary Clerk stall rather than only a hard network error,
   so a sustained Clerk outage would silently downgrade every PRO user to free
   and look identical to a fleet of genuinely free users. Now logs the reason.
   The verdict is still not cached, so the next request retries.

Also found and filed, NOT fixed here: #5384 - server/_shared/user-api-key.ts
cannot distinguish "key does not exist" from "Convex unavailable" and
negative-caches both for 60s, so a transient 5xx 401s a paying customer for a
full minute. Pre-existing; this PR's shape guard runs after cachedFetchJson and
neither causes nor worsens it. Fixing it changes the negative-caching contract on
a live auth path and deserves its own PR.

Verified: typecheck clean; mcp-proxy + auth-resource-timeout 64/64;
entitlement-check + gateway-user-key-apiaccess 40/40.

* fix(review): de-flake the live sweep and pin the edge cache-key behavior it exposed (#5379)

Third review round. A teammate reported the live suite failing against prod on
an assertion my own run had passed, which turned out to be the most useful
finding in the whole PR.

ROOT CAUSE. The suite's fake-auth assertions target URLs the edge caches
publicly for 600s, and the cache key does NOT include X-WorldMonitor-Key
(`vary: Origin` only). Verified directly against production:

  cache-busted URL + invalid key -> x-vercel-cache: MISS -> 401, no-store
  same URL once warm + same key  -> x-vercel-cache: HIT  -> 200, public (age 39)

So the ORIGIN auth logic is correct; the assertion outcome depended entirely on
whether the CDN happened to be holding an anonymous response. My run passed and
my teammate's failed for that reason alone. On a 6-hourly schedule that is an
intermittently-red job no PR can fix - precisely how a guard earns its way into
being ignored, which is the failure this PR exists to prevent.

FIXES

- Fake-auth probes are now cache-busted, so they test origin auth
  deterministically and keep testing the thing they are named after. Verified by
  three consecutive runs: 7 pass / 0 fail / 1 skip each time (previously the
  first assertion flipped with cache state).

- Added an explicit test pinning the cached-anonymous behavior, so it is a
  stated property rather than a flaky side effect. It asserts STRUCTURALLY that
  the payload served to an invalid key is the anonymous envelope carrying only
  the requested public key - so an entitled payload landing in a public cache
  entry (the #4497 incident) goes red. It also passes cleanly if the behavior is
  later fixed to 401, taking the fail-closed branch, so fixing the underlying
  issue will not require touching the test.

  My first version of that assertion compared byte-lengths across two live
  requests and was itself flaky (61512 vs 61287) - weather data changes between
  requests and different edge nodes hold differently-sized entries. Replaced
  with the structural check; adding a flaky test to a commit about flakiness
  would have been a poor joke.

Filed #5386 for the cache-key posture itself - the origin is right, the cache
key needs a product decision (accept / add to Vary / bypass-on-header), and
api/bootstrap-auth.test.mjs:302 currently asserts a contract production does not
honor when warm. Not fixed here: it is a CDN-rule change, not a code change.

ALSO FROM REVIEW

- api/mcp/auth.ts: documented `!ent` as intentionally-redundant defence-in-depth.
  It is unkillable by mutation - every read optional-chains to a falsy default,
  so a falsy ent already forces tier=0/mcpAccess=false/validUntil=0 and is
  rejected three times over (verified across all six falsy values, and by a
  double mutation dropping `!ent` AND `tier < 1` which still 401s). Comment-only;
  git diff confirms no logic change.

- server/auth-session.ts: corrected a comment that named VALIDATION_TIMEOUT_MS
  as living in server/_shared/user-api-key.ts. It does not - that file inlines
  the 3_000 literal; only api/_user-api-key.js has the constant.

Verified: typecheck + typecheck:api clean; vitest 816/816; tests/*.test.mts
4353/4353; tests/*.test.mjs + cli 11719 pass / 0 fail; mcp-proxy 59/59;
mcp-auth-entitlement-and-limits 73/73; live suite 7 pass / 0 fail / 1 skip,
stable across three consecutive runs.

* fix(review): make the anti-vacuous-pass test itself non-vacuous (#5379)

Fourth review round, and the third layer of the same lesson.

Two commits ago I removed stripComments because it deleted 30.2% of api/ and
let the #3803 timing-oracle guard pass vacuously, and I added a companion test
that plants a violation into every api/ file. Its comment promised: "any future
normalisation step that eats real code turns this test red."

That was false, and the reviewer proved it. The companion had its OWN private
readFile+match loop and never touched the real scan's code path. I reproduced it:
reintroduced the exact #3803-class stripper into the real scan's line ONLY, ran
the suite, and all 4 tests passed — including the one whose entire purpose is
catching that. A guard-of-a-guard with no teeth, which is precisely the bug it
was written to prevent, one level up.

FIX — route both paths through one seam.

normaliseForScan() is now the single normalisation point between reading a file
and matching it. It is the identity function today, deliberately; the point is
that anything which ever transforms source MUST live there, because the real
scan and the companion both call it. That shared routing is what turns the
companion into an actual safety net.

The companion also got two strengthenings, because sharing the seam alone was
not sufficient:

- A direct no-shrinkage assertion: normaliseForScan(source).length must equal
  source.length for every file. This states the violated property outright
  rather than waiting for a planted violation to happen to land in a swallowed
  region.
- Violations are planted at THREE positions (top, middle, bottom), not just
  appended. A swallowing normaliser eats a REGION, not a whole file — appending
  only at the end survives a stripper that ate the middle, and the companion
  would have stayed green while the real scan was blind. My first version made
  exactly that mistake.

MUTATION PROOF. Adding the old stripper to normaliseForScan now fails the
companion, naming 127 affected files:

  normaliseForScan DROPPED source for 127 file(s): api/[...notfound].ts,
  api/_agent-metadata.ts, ... Anything the scan cannot see, it cannot flag —
  this is how the guard silently stops working.

Before this commit the identical mutation left all 4 tests green.

Also confirmed from the same review pass, no change needed: the live-sweep
workflow's zero-assertion guard is sound. GitHub Actions runs `run:` steps under
`bash --noprofile --norc -eo pipefail`, so errexit aborts the step on a genuine
test failure before the grep is reached; the grep covers only the distinct
"zero assertions ran" case, which is what it is for.

tests/*.test.mts: 4353/4353 green.

* docs(ci): register live-api-cache-auth.yml in both docs-stats gates (#5379)

CI docs-stats went red on the new workflow. Adding a .github/workflows/*.yml
file trips TWO separate gates, and passing the first locally does not imply the
second:

1. npm run docs:check (docs-stats.mjs --check) -> needs a row in
   ARCHITECTURE.md's '## 11. CI/CD' table. Reproduced verbatim:
   'ARCHITECTURE.md: CI workflow live-api-cache-auth.yml is not listed in the
   CI/CD table'.
2. The CI docs-stats job ALSO regenerates and freshness-checks
   docs/generated/stats.json, which stores workflowCount plus the workflow
   filename list. Ran npm run docs:stats and committed the result:
   workflowCount 21 -> 22, filename added.

docs:check now reports OK, 80 doc claims match code.

* fix(lint): drop exports from the secret-compare test (biome noExportsInTest) (#5379)

CI biome went red with 3 errors, all mine: biome's lint/suspicious/noExportsInTest
forbids exporting from a test file, and this branch had added three exports
(buildSecretComparePattern, PATTERN_CASES, normaliseForScan). origin/main has
zero exports in this file, so the branch introduced them.

Removed the export keywords rather than suppressing the rule. The stated
rationale for exporting was auditability from outside the runner, but nothing
imports this file and the property that actually matters is unaffected: the real
scan and the planted-violation companion are in the SAME module, so they still
share one buildSecretComparePattern and one normaliseForScan seam.

Re-verified the seam still has teeth after the change — adding a comment-stripper
to normaliseForScan reds the companion, naming 124 files:

  normaliseForScan DROPPED source for 124 file(s): api/[...notfound].ts, ...

4/4 green restored; npm run lint (biome + enforce-safe-html) passes clean.

* docs(solutions): capture the verify-the-verifier convention from the #5379 sweep

New: docs/solutions/conventions/verify-the-verifier-mutation-test-every-detection-layer.md

The durable lesson from #5379 / PR #5385 is not any individual auth bug — it is
that four times in one PR, a layer built to DETECT silent failure had a silent
failure of its own, and each was found only by breaking the detector and
watching whether it noticed:

  1. a live suite inert because nothing set its gating env var (CI passed it
     having run zero assertions);
  2. the CI workflow written to fix that, which could itself pass on zero tests
     because node --test exits 0 when everything skips;
  3. a security regression guard passing vacuously because its comment-stripper
     deleted 30.2% of api/ before matching;
  4. the anti-vacuity companion added to fix (3), which had its own private code
     path and never exercised the real scan — proven by mutation.

The doc's reusable core is the smell (a negative assertion passes silently when
its input shrinks, so anything that can shrink the input must be separately
pinned) and the three-part recipe, with the emphasis that part 1 alone is
necessary but NOT sufficient — that is the layer-4 lesson and the part most
likely to be skipped.

Classified knowledge-track / convention rather than best-practice (the explicit
fallback): this is a rule to apply on every future test, CI-gate, or guard PR.
First doc in docs/solutions/conventions/.

Overlap adjudicated as Moderate, not High, so created rather than merged:
best-practices/test-guard-assertions-and-module-state-reset.md (PR #5369/#5370,
two PRs earlier on this same auth surface) shares the mutation-proof PRINCIPLE
but covers different mechanisms (JSON.stringify coercing Infinity; a leaked
module-state reset). Cross-linked both directions in the Related section, along
with the country-scope 'mirror test' doc and the ttl-staleness doc (whose static
audit fails the opposite way — over-matching rather than under-matching).

CONCEPTS.md: added a 'Test & Guard Verification' cluster defining Vacuous Guard
and Mutation Proof — both used with precise project-specific meaning across five
documented recurrences now, and neither previously defined.

Grounded: every file:line citation verified against the tree; the central claim
(real scan and companion both route through the shared normaliseForScan seam)
confirmed at :268, :309 and :324; merge state confirmed OPEN/18-pass at write
time. Frontmatter passes the parser-safety validator.

* fix(review): harden auth verification guards

* chore: refresh PR mergeability state

* fix(auth): close review hardening gaps

* test(auth): stub prevalidation limiter in gateway suite

* test(mcp): wire pre-auth guard in world brief fixture

* fix(global-tenders): spread SAM.gov request budget, stop retrying 429s (#5444) (#5457)

* fix(global-tenders): spread SAM.gov request budget, stop retrying 429s (#5444)

SAM.gov enforces a small per-key daily quota (10/day for non-federal
keys). The hourly seed fetched SAM every tick and retried 429s in-run —
up to ~72 requests/day against that budget — so once the quota tripped,
every subsequent run 429'd, the source pinned at 'stale', and its age
climbed past the 180-minute ceiling (health SEED_ERROR, empty US tender
queries).

- pace SAM fetches: skip the request while the previous success is
  fresher than 150 minutes (~9.6 requests/day), carrying the prior
  records through with lastSuccessfulAt untouched so staleness
  accounting stays honest
- treat SAM 429s as non-retryable in-run via a retry429 opt-out in
  fetchResponse (quota-style limits do not clear in seconds; retries
  only burn more budget)
- thread previousSnapshot through to source adapters so pacing can see
  the prior sam status

Tests cover the pacing skip, interval expiry, unchanged first-run
behaviour, and the no-retry-on-429 contract; all 28 existing tender
tests unchanged.

* fix(global-tenders): preserve paced source health (#5457)

- keep stale and error SAM states degraded during paced runs

- align the SAM health window with its effective request cadence

- publish paced in the proto, clients, OpenAPI, and MCP contract

* fix(security): update pro-test postcss (#5457)

Pin postcss 8.5.12 to clear GHSA-6g55-p6wh-862q in the production dependency audit.

---------

Co-authored-by: Elie Habib <[email protected]>

* fix(forecast): extend EIA settlement grace (#5524)

* fix(forecast): extend EIA settlement grace

* test(forecast): cover missing EIA metric grace (#5524)

* feat(api): enforce the sold daily cap + informative at-cap 429 (#4635) (#4684)

The enforcement half of the #4635 lifecycle, behind API_RATE_LIMIT_ENFORCE
(shadow-safe until the flip):

- U5: reserveDailyMeter now flags at the SOLD allowance (Starter 1,000/day),
  not the 10x ceiling -- `overLimit: count > allowance` (was
  `count > allowance * CEILING_MULTIPLIER`). CEILING_MULTIPLIER removed; the
  `allowance <= 0` guard keeps Enterprise (-1) uncapped. Renamed the meter's
  `overCeiling` field -> `overLimit`; the daily X-RateLimit-Limit header now
  advertises the sold cap (was 10x).
- U4: the burst + daily 429 bodies now carry
  { error, plan, limit, limit_type, reset, upgrade_url } instead of a bare
  "Too many requests" / "Daily request ceiling exceeded". `planKey` is hoisted
  at the 429 sites; enterprise (top tier) omits upgrade_url. Additive body
  contract; X-RateLimit-* header shape unchanged. Telemetry reason strings
  (rl_ceiling_*) kept for dashboard/audit continuity.

Meter 16/16 + gateway 18/18 tests green; typecheck:api clean. Unblocks the
API_RATE_LIMIT_ENFORCE flip once the notify/upgrade stack lands.

Claude-Session: https://claude.ai/code/session_01SjVX3GyMBmC2XyFWCHogN1

* fix(security): bump find-my-way to 9.7.0 — GHSA-c96f-x56v-gq3h (HTTP/2 DDoS) (#5527)

A new high advisory against find-my-way <= 9.6.0 (fastify's router, transitive
in consumer-prices-core) landed today and reds the audit-lockfile
(consumer-prices-core) check on every PR, while path-filtered main stays green.
Lockfile-only bump to the first patched version (9.7.0); verified with the
exact CI invocation:

  node .github/scripts/audit-production-dependencies.mjs --workspace
  consumer-prices-core ... -> "Production audit OK ... 0 high+ advisories"

Unblocks #5526 and any other open PR.
Claude-Session: https://claude.ai/code/session_01StNurp4TGC3JLHbTtKJhbp

* fix(payments): restore lapsed subscriber reactivation path (#5532)

* fix(payments): restore lapsed subscriber reactivation path

* fix(deps): patch vulnerable find-my-way release

* fix(ui): cancel stale pro banner removal

* fix(review): correct subscriber reactivation edge cases

* chore(bootstrap): wind down the R2-origin experiment (DebugBear sampling + KTD7 no-go) (#5536)

* fix(seo): remove unsupported schemamap robots directive (#5544)

* test(bootstrap): cover DebugBear sampling boundaries (#5545)

* Merge commit from fork

* fix(payments): persist failed Dodo webhook incidents (#5533)

* fix(payments): persist failed Dodo webhook incidents

* fix(payments): close Dodo webhook failure lifecycle

* Address PR review feedback (#5533)

- Keep recovery bookkeeping errors out of processing incident recording\n- Serialize failure lifecycle updates with the seeded aggregate lock\n- Add concurrent regression coverage and deploy seeding\n\nNote: pre-existing failure in convex/__tests__/poolSelection.test.ts not addressed by this PR.

* fix(payments): harden webhook failure rollout

* fix(review): dedupe resolution transition, cover aggregate lifecycle branches

- Extract applyWebhookFailureResolution so manual resolve and provider-retry
  recovery share one timestamped transition (review: maintainability)
- Add lifecycle tests for changed-eventType redelivery and record-after-
  resolve reopening (review: testing)
- Correct the ops-signal comment: production attempts the queue and logs
  scheduler failures without changing the retry response (review: maintainability)

* fix(giving): disclose published benchmark provenance (#5540)

* fix(giving): disclose published estimate provenance

* fix(giving): disclose benchmark provenance in panel

* test(giving): verify benchmark provenance in browser

* fix(review): harden giving provenance states

* fix(ci): refresh documentation component counts

* fix(ci): retain valid giving data on refresh failure

* fix(giving): close review recovery gaps

* fix(payments): dead-letter authenticated malformed webhooks instead of 401 (#5547)

Split signature verification from payload validation in the Dodo webhook
handler. 401 is now reserved for credentials that fail HMAC verification;
a well-signed payload that fails JSON parsing or schema validation records
a sanitized dead-letter projection and returns 500, so permanent provider-
side defects exhaust retries into a repairable incident rather than a
mislabeled signature failure (review: adversarial, PR#5533 finding #1).

- verifyDodoSignature mirrors the SDK's vendored standardwebhooks scheme
  (whsec_ base64 secret, HMAC-SHA256, v1 signatures, 5min tolerance)
- Payload validation uses the SDK's exported WebhookPayloadSchema, so the
  accepted shape is identical to verifyWebhookPayload
- Extract persistFailureAndSignal shared by the validation and processing
  failure paths
- Tests: signed schema-invalid and unparseable bodies dead-letter with
  500; a bad signature still 401s with no failure row

* fix(routing): redirect pricing to canonical section (#5548)

* feat(i18n): Hoàn thiện bản dịch tiếng Việt (vi.json) (#5539)

* feat: hoàn thiện bản dịch tiếng Việt cho vi.json

Dịch 168 giá trị chưa dịch sang tiếng Việt (từ 247 giá trị ban đầu).
79 giá trị còn lại giữ nguyên (thuật ngữ kỹ thuật/tên riêng: PRO, AI, DDoS, WTO, AWACS, macOS, WhatsApp, MMSI, SQUAWK...).

Các danh mục đã dịch:
- header: Tech News → Tin tức Công nghệ, Cybersecurity → An ninh mạng...
- panels: Gold Intelligence → Thông tin Vàng, Fear & Greed → Sợ hãi & Tham lam...
- components: Central Banks → Ngân hàng Trung ương, Data Freshness → Độ mới Dữ liệu...
- popups: US/NATO → MỸ/NATO, CHINA → TRUNG QUỐC, Recent Tracking → Theo dõi gần đây...
- modals: Add → Thêm, download banners...
- dashboardTabs: Main → Chính, New Tab → Tab mới, Add tab → Thêm tab...
- countryBrief, commands, widgets, preferences, premium

* fix(i18n): correct Vietnamese tooltip translations

---------

Co-authored-by: Elie Habib <[email protected]>

* fix(docker): preserve caller auth for local MCP requests (#5492)

* fix(docker): preserve caller auth for local MCP requests

Fixes #5471

Signed-off-by: Arpit Tagade <[email protected]>

* fix(sidecar): strip transport token from cloud proxies

---------

Signed-off-by: Arpit Tagade <[email protected]>
Co-authored-by: Elie Habib <[email protected]>

* fix(docker): preserve caller auth for local MCP requests (#5537)

* fix(docker): preserve caller auth for local MCP requests

Fixes #5471

Signed-off-by: Arpit Tagade <[email protected]>

* fix(sidecar): strip transport token from cloud proxies

---------

Signed-off-by: Arpit Tagade <[email protected]>
Co-authored-by: Elie Habib <[email protected]>

* fix(seo): prevent raw-text crawlers from extracting "WWorld Monitor" Move the decorative "W" brand mark from inline text to a CSS ::after pseudo-element. Raw textContent extraction now yields "World Monitor" instead of "WWorld Monitor". The visual appearance, accessible name, and first-paint footprint are unchanged — the ::after inherits the same font-size and color from .skeleton-brand-mark. Adds focused regression tests in deploy-config.test.mjs asserting: - Raw text does not contain "WWorld" - The brand mark span has no text content - The "W" is rendered via CSS content Fixes #5541 (#5549)

* feat(activation): day-0 pro activation onboarding interstitial (#5534)

* feat(activation): pure pro-activation state core — mount decision, step model, fire-once keying (U1)

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* feat(activation): interstitial shell — overlay, step chrome, focus trap, exit summary, en copy (U3)

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* feat(activation): brief/alerts/power step wiring + finish-setup chip (U4-U6)

Brief: atomic setNotificationConfig with explicit hour+IANA tz, insights world-brief preview, inline hour select. Alerts: pre-denied blocked state, patch-not-clobber channels, cadence-honest copy. Power: injected deep links + R8 settings pointer. Chip: versioned-key dismissal.

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* feat(activation): checkout-return marker + post-reload boot mount hook (U2)

Marker written before clearCheckoutAttempt on the success branch only; mount
decision evaluated off the boot critical path with bounded snapshot-retry.
Surfaces subscriptionId/currentPeriodStart on getSubscriptionForUser (additive,
existing columns) for the fire-once key — accepted plan deviation.

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* fix(activation): re-arm mount retry on subscription snapshot changes too

With the real subscription snapshot as the fire-once key input, a boot with
live entitlement but a not-yet-loaded subscription snapshot would stall in
'keep' forever watching entitlement only.

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* feat(activation): proActivation locale fan-out — 24 locales, register-calibrated (fa per its file convention)

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* feat(activation): funnel telemetry + end-to-end spec (U7)

Typed Umami events with whitelisted minimized payloads (planKey/step/exit
counts — never billing identifiers); single entered fire site at mount; 7
Playwright scenarios green against the dev server.

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* refactor(activation): simplify pass — shared focus-trap util, leaf record parsers, type reuse, idle-handle cleanup

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* fix(review): apply findings #1-4, #8-12, #14 — hour-capture ref, seeded alerts fallback, preview tri-state, coverage locks

P0 #1: digest hour captured in a closure ref so the in-flight re-render can't
wipe the user's pick. P1 #2: alerts catch-path seeds from flow context (+email
when brief confirmed) instead of empty — convex channels field is full-replace.
P1 #3 + P2 #8-12: failed-state e2e, chip assertion, expired-Pro branch, payload
combo, catalog-derived drift guard, focus-trap unit tests. P2 #4 tri-state
preview guard. P3 #14 unexported helper.

Claude-Session: https://claude.ai/code/session_01VUcnpsWficDVsPmEZEUJP7

* fix(review): apply findings #6, #7, #13 — account-scoped records, cross-tab mount claim

Marker/fire-once/chip records carry the Clerk userId; foreign-user markers
never mount (and are left for the buyer, TTL-reaped); unscoped marke…
koala73 added a commit that referenced this pull request Jul 26, 2026
… mutation-proof

Code review of #5605 found the outcome signal wrong in both directions and
all three new regression guards passing with the bug restored (proven by
execution). Addresses every finding.

Classification (#1, #5, #8) — `lastAttemptedProvider === 'none'` conflated
causes:
- too quiet: canAttemptServerSummarization() denies both an anon/free
  principal AND an entitled one inside the 403/429 cooldown, so a paying
  user's live entitlement outage was demoted to console.debug for up to
  15 min (24 h via Retry-After). trackLLMFailure is a no-op and no
  captureConsoleIntegration exists, so that was the only signal — the #5600
  shape. summarize-gate now exports isServerSummarizationSuppressed() and the
  chain records which denial it hit.
- too loud: CircuitBreaker.execute returns its default WITHOUT running the
  callback while on cooldown (breaker defaults maxFailures=2, cooldown=5min),
  so a chain that contacted nobody still reported "All providers failed".
  Marking now happens INSIDE the breaker callback, and a short-circuit is
  recorded as its own cause.
- the quiet path no longer claims "using designed fallback"; reaching it means
  browser T5 never ran and callers render an error/unavailable state.

Guards (#2, #3, #6) — each of these was green with the bug restored:
- indexOf matched the gate name inside a COMMENT, so moving the mark above the
  gate passed. Source assertions now strip comments; more importantly the mark
  lives inside the breaker callback, making the ordering structural.
- the locality assertion ran against the whole file, so hoisting the state to
  module scope passed. Now scoped to generateSummary with a creation-count pin
  and a broadened module-global check.
- the raw-warn regex only matched quoted literals while this file's idiom is a
  template literal. Now delimiter-agnostic.
- slice anchors are asserted to resolve; a renamed anchor made slice(a, -1)
  silently widen to end-of-file.

The decision surface moved into a pure classifier covered by an executed truth
table rather than grep. Verified: all three original bypasses now fail (32/32
green, 31/32 with each mutation applied); typecheck and biome clean.

Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE
koala73 added a commit that referenced this pull request Jul 26, 2026
… mutation-proof

Code review of #5605 found the outcome signal wrong in both directions and
all three new regression guards passing with the bug restored (proven by
execution). Addresses every finding.

Classification (#1, #5, #8) — `lastAttemptedProvider === 'none'` conflated
causes:
- too quiet: canAttemptServerSummarization() denies both an anon/free
  principal AND an entitled one inside the 403/429 cooldown, so a paying
  user's live entitlement outage was demoted to console.debug for up to
  15 min (24 h via Retry-After). trackLLMFailure is a no-op and no
  captureConsoleIntegration exists, so that was the only signal — the #5600
  shape. summarize-gate now exports isServerSummarizationSuppressed() and the
  chain records which denial it hit.
- too loud: CircuitBreaker.execute returns its default WITHOUT running the
  callback while on cooldown (breaker defaults maxFailures=2, cooldown=5min),
  so a chain that contacted nobody still reported "All providers failed".
  Marking now happens INSIDE the breaker callback, and a short-circuit is
  recorded as its own cause.
- the quiet path no longer claims "using designed fallback"; reaching it means
  browser T5 never ran and callers render an error/unavailable state.

Guards (#2, #3, #6) — each of these was green with the bug restored:
- indexOf matched the gate name inside a COMMENT, so moving the mark above the
  gate passed. Source assertions now strip comments; more importantly the mark
  lives inside the breaker callback, making the ordering structural.
- the locality assertion ran against the whole file, so hoisting the state to
  module scope passed. Now scoped to generateSummary with a creation-count pin
  and a broadened module-global check.
- the raw-warn regex only matched quoted literals while this file's idiom is a
  template literal. Now delimiter-agnostic.
- slice anchors are asserted to resolve; a renamed anchor made slice(a, -1)
  silently widen to end-of-file.

The decision surface moved into a pure classifier covered by an executed truth
table rather than grep. Verified: all three original bypasses now fail (32/32
green, 31/32 with each mutation applied); typecheck and biome clean.

Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE
koala73 added a commit that referenced this pull request Jul 26, 2026
…by-design chains (#5377) (#5605)

* fix(summarization): stop logging 'All providers failed' for declined-by-design chains (#5377)

Both chain-exhausted sites (BETA + normal mode) warned 'All providers
failed' even when nothing was attempted — the entitlement gate (#4913/#4915)
had declined every server dispatch and browser T5 was unavailable, which is
the designed anonymous path. The outage-shaped warning is indistinguishable
from a real provider failure in console triage and cost the #5377
investigation two wrong hypotheses.

Route both sites through logChainOutcome(): when lastAttemptedProvider is
still 'none' (tryApiProvider marks attempts only after the feature +
entitlement gates; tryBrowserT5 only after the mlWorker availability check)
log at debug with an accurate 'skipped: no eligible provider' message;
reserve warn for chains where a provider was genuinely attempted and failed.

Note: #5377's part 1 (the enqueue gate — zero anon summarize dispatches)
already landed in #4915 via configureSummarizeGate(hasPremiumAccess) and is
pinned by tests/summarize-entitlement-gate; this closes the remaining
acceptance item.

Tests: extended tests/summarize-entitlement-gate.test.mts — the fail sites
must route through logChainOutcome (no unconditional string-literal 'All
providers failed' warn), debug-before-warn branch on 'none', and attempt
marking must stay behind the gates so 'none' means declined-by-design.
21/21 green; RED (1 fail) against the pre-fix source. tsc clean.

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

* fix(summarization): isolate provider attempt tracking

* docs: update service module count

* fix(review): make the #5377 outcome classifier correct and its guards mutation-proof

Code review of #5605 found the outcome signal wrong in both directions and
all three new regression guards passing with the bug restored (proven by
execution). Addresses every finding.

Classification (#1, #5, #8) — `lastAttemptedProvider === 'none'` conflated
causes:
- too quiet: canAttemptServerSummarization() denies both an anon/free
  principal AND an entitled one inside the 403/429 cooldown, so a paying
  user's live entitlement outage was demoted to console.debug for up to
  15 min (24 h via Retry-After). trackLLMFailure is a no-op and no
  captureConsoleIntegration exists, so that was the only signal — the #5600
  shape. summarize-gate now exports isServerSummarizationSuppressed() and the
  chain records which denial it hit.
- too loud: CircuitBreaker.execute returns its default WITHOUT running the
  callback while on cooldown (breaker defaults maxFailures=2, cooldown=5min),
  so a chain that contacted nobody still reported "All providers failed".
  Marking now happens INSIDE the breaker callback, and a short-circuit is
  recorded as its own cause.
- the quiet path no longer claims "using designed fallback"; reaching it means
  browser T5 never ran and callers render an error/unavailable state.

Guards (#2, #3, #6) — each of these was green with the bug restored:
- indexOf matched the gate name inside a COMMENT, so moving the mark above the
  gate passed. Source assertions now strip comments; more importantly the mark
  lives inside the breaker callback, making the ordering structural.
- the locality assertion ran against the whole file, so hoisting the state to
  module scope passed. Now scoped to generateSummary with a creation-count pin
  and a broadened module-global check.
- the raw-warn regex only matched quoted literals while this file's idiom is a
  template literal. Now delimiter-agnostic.
- slice anchors are asserted to resolve; a renamed anchor made slice(a, -1)
  silently widen to end-of-file.

The decision surface moved into a pure classifier covered by an executed truth
table rather than grep. Verified: all three original bypasses now fail (32/32
green, 31/32 with each mutation applied); typecheck and biome clean.

Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE

* chore(docs): regenerate stats after rebase onto main

Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Elie Habib <[email protected]>
benngee85 pushed a commit to benngee85/VISTA that referenced this pull request Jul 26, 2026
…by-design chains (koala73#5377) (koala73#5605)

* fix(summarization): stop logging 'All providers failed' for declined-by-design chains (koala73#5377)

Both chain-exhausted sites (BETA + normal mode) warned 'All providers
failed' even when nothing was attempted — the entitlement gate (koala73#4913/koala73#4915)
had declined every server dispatch and browser T5 was unavailable, which is
the designed anonymous path. The outage-shaped warning is indistinguishable
from a real provider failure in console triage and cost the koala73#5377
investigation two wrong hypotheses.

Route both sites through logChainOutcome(): when lastAttemptedProvider is
still 'none' (tryApiProvider marks attempts only after the feature +
entitlement gates; tryBrowserT5 only after the mlWorker availability check)
log at debug with an accurate 'skipped: no eligible provider' message;
reserve warn for chains where a provider was genuinely attempted and failed.

Note: koala73#5377's part 1 (the enqueue gate — zero anon summarize dispatches)
already landed in koala73#4915 via configureSummarizeGate(hasPremiumAccess) and is
pinned by tests/summarize-entitlement-gate; this closes the remaining
acceptance item.

Tests: extended tests/summarize-entitlement-gate.test.mts — the fail sites
must route through logChainOutcome (no unconditional string-literal 'All
providers failed' warn), debug-before-warn branch on 'none', and attempt
marking must stay behind the gates so 'none' means declined-by-design.
21/21 green; RED (1 fail) against the pre-fix source. tsc clean.

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

* fix(summarization): isolate provider attempt tracking

* docs: update service module count

* fix(review): make the koala73#5377 outcome classifier correct and its guards mutation-proof

Code review of koala73#5605 found the outcome signal wrong in both directions and
all three new regression guards passing with the bug restored (proven by
execution). Addresses every finding.

Classification (koala73#1, koala73#5, koala73#8) — `lastAttemptedProvider === 'none'` conflated
causes:
- too quiet: canAttemptServerSummarization() denies both an anon/free
  principal AND an entitled one inside the 403/429 cooldown, so a paying
  user's live entitlement outage was demoted to console.debug for up to
  15 min (24 h via Retry-After). trackLLMFailure is a no-op and no
  captureConsoleIntegration exists, so that was the only signal — the koala73#5600
  shape. summarize-gate now exports isServerSummarizationSuppressed() and the
  chain records which denial it hit.
- too loud: CircuitBreaker.execute returns its default WITHOUT running the
  callback while on cooldown (breaker defaults maxFailures=2, cooldown=5min),
  so a chain that contacted nobody still reported "All providers failed".
  Marking now happens INSIDE the breaker callback, and a short-circuit is
  recorded as its own cause.
- the quiet path no longer claims "using designed fallback"; reaching it means
  browser T5 never ran and callers render an error/unavailable state.

Guards (koala73#2, koala73#3, koala73#6) — each of these was green with the bug restored:
- indexOf matched the gate name inside a COMMENT, so moving the mark above the
  gate passed. Source assertions now strip comments; more importantly the mark
  lives inside the breaker callback, making the ordering structural.
- the locality assertion ran against the whole file, so hoisting the state to
  module scope passed. Now scoped to generateSummary with a creation-count pin
  and a broadened module-global check.
- the raw-warn regex only matched quoted literals while this file's idiom is a
  template literal. Now delimiter-agnostic.
- slice anchors are asserted to resolve; a renamed anchor made slice(a, -1)
  silently widen to end-of-file.

The decision surface moved into a pure classifier covered by an executed truth
table rather than grep. Verified: all three original bypasses now fail (32/32
green, 31/32 with each mutation applied); typecheck and biome clean.

Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE

* chore(docs): regenerate stats after rebase onto main

Claude-Session: https://claude.ai/code/session_018XDm48Kzuv1GQe4PR1qimE

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Elie Habib <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: infrastructure Cables, pipelines, power grids, cyber threats codex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants