[pull] main from koala73:main - #2
Open
pull[bot] wants to merge 4532 commits into
Open
Conversation
## Summary Licensed news coverage can now consume 16 additional publisher channels. Eleven sources use native RSS, while Interfax EN, PR Newswire, Coinbase, Binance, and Jin10 use bounded publisher-scoped Google News queries with the originating publisher recorded separately from the acquisition transport. The existing dashboard variants, server digest, and digest-backed MCP tools all consume the same catalog additions. The safety model stays explicit: redirect targets are RSS-allowlisted, localized feeds remain language-gated, Interfax RU and EN collapse to one publisher family, and press-release distributors are not classified as independent wire reporting. Acquired publisher rights and required attribution are recorded in the generated source inventory. ## Validation - Live-probed all 11 native feeds and five Google News queries; each returned publisher-scoped content. - `./node_modules/.bin/tsx --test tests/direct-news-feed-pack.test.mts tests/feed-catalog-drift.test.mts tests/feeds-client-server-parity.test.mjs tests/source-provenance.test.mts tests/publisher-families.test.mjs tests/source-attribution.test.mjs tests/scripts-shared-mirror.test.mjs api/rss-proxy.test.mjs tests/edge-functions.test.mjs` — 415 passed. - `npm run typecheck` and `npm run typecheck:api` passed. - The pre-push gate passed architectural, safe-HTML, Sentry-coverage, rate-limit, premium-fetch, Edge-bundle, server-handler, and changed-test checks. - `node scripts/source-attribution.mjs --check` and all shared/scripts mirror checks passed. --- [](https://github.com/EveryInc/compound-engineering-plugin) 
## Summary First-party take of four ideas from rejected PR #6696 — not a merge of that fork. - **Tape Claim:** a configured Finnhub/Yahoo key is not a live tape. Settings and market copy no longer say “real-time” for those quotes. - **`/stocks/:symbol`:** research overlay composing the existing Yahoo/sparkline chart plus premium AnalyzeStock. Market Panel stays a watchlist and now deep-links here. - **China factory registry:** reviewed MIIT 2024 cluster → HS list. National Comtrade captions on Trade Policy, China corridors, and CN deep-dive. Unmapped clusters do not join numbers. - **News hygiene:** AnalyzeStock headlines carry US-session alignment. Sentiment stays a separate, omitted-when-unavailable model overlay. No “headline caused the move.” ## Test plan - [x] Focused unit tests (tape-claim, stock route, china-factory-clusters, stock-market-session, stock-analysis) - [x] `npm run typecheck` and `npm run typecheck:api` - [x] Pre-push gate green - [ ] Open `/stocks/AAPL` on a local dashboard (browser not run in this worktree) ## Out of scope Massive/Polygon, eight new Market RPCs, factory explorer page, maritime kiosk, fork brand, health-probe remapping.
## Summary `weather_alert` notifications now include `lat`/`lon`, plus polygon `geometry` when NWS supplied a ring. The seed already computed that centroid and dropped it at the publish boundary. That was the hard ceiling on proximity alerting. Alerts with no usable centroid omit the new fields. Existing consumers keep the same title, source, country, and coalesce behavior. Fixes #6625 ## Type of change - [x] New feature - [ ] Bug fix - [ ] New data source / feed - [ ] New map layer - [ ] Refactor / code cleanup - [ ] Documentation - [ ] CI / Build / Infrastructure ## Affected areas - [ ] Map / Globe - [ ] News panels / RSS feeds - [ ] AI Insights / World Brief - [ ] Market Radar / Crypto - [ ] Desktop app (Tauri) - [ ] API endpoints (`/api/*`) - [ ] Config / Settings - [x] Other: weather_alert notification payload ## Checklist - [ ] Tested on [worldmonitor.app](https://worldmonitor.app) variant - [ ] Tested on [tech.worldmonitor.app](https://tech.worldmonitor.app) variant (if applicable) - [ ] New RSS feed domains added to `api/rss-proxy.js` allowlist (if adding feeds) - [x] No API keys or secrets committed - [x] TypeScript compiles without errors (`npm run typecheck`) - [ ] New or repointed health probes have a completed Railway-side pre-seed, or an owner-bound baseline acknowledgement with an entry-level `expiresAt` bounded to the first scheduled cron window (if applicable) ## Documentation Alignment Checklist N/A — this PR does not publish or change documentation claims. - [ ] Claim ledger attached or linked - [ ] All required Audit Council role signoffs attached - [ ] Generated docs regenerated from proto where applicable - [ ] Fixture-backed examples recomputed - [ ] Redis writers/readers enumerated for every documented key ## Related Related: #6271 Related: #6156 ## Validation Pre-push ran `tsc --noEmit`, `typecheck:api`, and 27 focused tests: weather-alert selection, notify-location mapping (lat/lon, omitted missing centroid, rejected non-finite centroid, publish-site wiring), and the Dockerfile.relay COPY guard for the dynamic import. ## Screenshots Not applicable — this is a notification payload change with no UI surface.
…nvariant (#6705) `WORLDMONITOR-TR` has regressed **eight times**. Every round enumerated one more injected font host, and every round shipped the *same* justification in its comment — "our `font-src` is `'self' data:`, so this cannot be ours" — before a different extension injected a different host days later. Round 9 was queued up to be `assets.faircado.com`. This stops enumerating the hosts and applies the invariant once. Sixteen host rules become one policy-aware check; the diff is **−221 lines**. ## Why the per-host approach cannot converge The suppressed set is a list of browser extensions — Perplexity Comet, Doubao, Migaku, iconfont/alicdn, Slant, ShopBack, SimplyCodes, scite.ai, Typekit, FontAwesome, MerciApp, Yiban, Alipay marmot, unpkg, jsDelivr, cdnjs. Any extension can inject any host, so the set is vendor-controlled and unbounded. Two of the eight rounds were not even new hosts, but new *shapes* on a known host that the shared `fontFile` matcher missed (round 3's `.otf` fallback member, round 7's extensionless `/l/font` subsetting endpoint) — the enumeration leaks along two axes at once. ## The invariant The catch-all Vercel route serving `/dashboard` ships `font-src 'self' data:` and the app self-hosts every face. That policy admits **no** cross-origin source, so a cross-origin font block is by construction a face we did not request. A bare `https:` check is sufficient to mean "cross-origin" here: `'self'` already permits our own origin, so a same-origin font never produces a violation at all. The existence of an https `font-src` violation is itself proof the URI was not same-origin. ## What keeps it honest **It is policy-aware, not a blanket assumption** — the same shape as the existing `connect-src`/`media-src` gates, which already take the live policy as a parameter rather than assuming a protocol. If the app ever adopts a cross-origin font host, the flag flips and font blocks surface again. **A new deploy-config guard pins the premise.** `tests/deploy-config.test.mjs` now asserts the shipped header admits zero cross-origin font sources, across both the Vercel and nginx surfaces, and its failure message names the filter that depends on it. Adopting a remote font host fails that assertion *first*, before the filter can silently hide a real regression. I verified the guard fires by injecting `https://fonts.example.com` into `vercel.json`. **Scope is `font-src` only.** `style-src`/`script-src`/`connect-src` keep exact-host pinning, because a blocked script or stylesheet can indicate a real injection vector, whereas a blocked webfont is cosmetic and already mitigated by the block itself. The four non-font assertions inside the deleted per-host tests are preserved verbatim. ## What this trades away The deleted tests asserted host-pinning for `font-src` — lookalike hosts, sibling registrable domains, off-signature paths. That property is deliberately dropped for this one directive: the safety moves from *the filter enumerating hosts* to *the CSP admitting none*, guarded by the deploy test above. A `font-src` report tells us a font was successfully blocked, which is the control working; losing that report costs observability we were already drowning in 357k events of extension noise. ## Validation - Sized on the window **strictly after round 8 went live** (07:25 UTC — the commit stamp is `+04:00`, which is easy to misread as 11:25): cdnjs and jsDelivr both drained to **zero**, and the only escapes were 30 `assets.faircado.com` events inside a single second — one page load's font fallback chain. Round 8 worked; the design is what keeps failing. - `tests/csp-filter.test.mjs` 129/129, `tests/deploy-config.test.mjs` 179/179, `npx tsc --noEmit` clean. - **Mutation-tested.** Removing the rule (20 red), ignoring the policy flag (1 red — the policy-aware test), and dropping the protocol conjunct (4 red — the non-https test) each turn the specific test written for them red, so none of the new tests is vacuous. Related: [WORLDMONITOR-TR](https://elie-habib.sentry.io/issues/7574329048/) **Left deliberately unresolved in Sentry.** Plain-resolving an issue that is still firing guarantees it bounces back within minutes — which is part of why these keep reappearing. It stays unresolved until this deploys, so the post-deploy drop to zero is observable, and gets resolved on that evidence. --- [](https://github.com/EveryInc/compound-engineering-plugin)  https://claude.ai/code/session_01LBZyutschaksw2oWE7rfX4
#6288) (#6681) ## What The serialized collector queue has one in-flight slot, released only by the `.finally()` on `runCollectorRequest` — which cannot run until `await responsePromise` returns. That promise belongs to whatever `window.fetch` was at install time. #6088 gave every write a deadline, but that deadline is **request-side**: an `AbortController` only settles a fetch if the implementation underneath honors the signal, and nothing verified that it does. `src/bootstrap/sentry-init.ts` documents third-party `window.fetch` wrapping as a live condition on this exact collector host. Two wrapper shapes defeat it — one that rebuilds the request (`orig(new Request(url, {method, headers, body}))`, the standard shape for RUM SDKs that re-time requests) silently drops `init.signal`; one that re-wraps the promise without forwarding rejection never settles at all. On the native branch the entire deadline lives inside the `AbortSignal.timeout` object such a wrapper discards, so the path carrying essentially all real traffic had strictly **less** module-side protection than the compatibility path #6088 hardened. This releases the slot on a deadline the module owns, raced against the awaited response, so it never depends on the callee. The abort still goes out — cancelling a well-behaved transport is correct; the race only stops a badly-behaved one from holding the slot for the life of the page. **The deadline spans the body read, not just the headers.** `fetch` resolves when headers arrive; the body is a separate stream that `inspectCollectorResponse` reads with `response.text()`. Racing only the headers leaves the identical wedge one line later. ## The double-commit caveat, closed at both doors Losing the race **abandons** the request but cannot cancel it, so a raced-out append-only conversion may still commit. The failure carries a `raced` marker (a marker, not a `kind` — the `botFiltered` precedent) and both re-send doors close for conversions: | Door | Before | After | |---|---|---| | In-page retry (`isRetryableCollectorFailure`) | `timeout` -> retry | `raced` -> no retry | | Boot replay (`isDurableMarkerResolved`, analytics.ts) | keys off `kind === 'http'`, so a raced `timeout` reads as "never answered, safe to replay" | `raced` -> marker settled, no replay | The second door is easy to miss: closing only the in-page retry would leave the boot replay to smuggle back exactly the duplicate the first door just refused. Identity writes stay retryable — an idempotent latest-snapshot overwrite has no duplicate hazard, and that idempotency is what lets the race *recover* them rather than only drop them. ## Keeping the incident visible This fix removes `queue-overflow`, which was the parked page's **only** outward symptom — so a raced timeout has to be able to report, or the population goes dark exactly when the bug is fixed. It could not, three times over: the `>=5 writes/window` noise floor (a parked page abandons ~2 writes per window and can never clear it), the once-per-cohort `noiseReported` latch (checked *before* the per-signature dedup, so any earlier blocked request suppressed it), and the aggregate-first path (reports to Sentry only when the cross-user aggregate *declines* — never, on a healthy deployment). A raced timeout now reports on its own, still feeding the aggregate, still deduped to one event per signature per window. It also gets its own `fingerprint` segment: unlike #6289's `usedFallback` — a population attribute orthogonal to the failure — `raced` correlates with a behavior change (these are the events that will never be retried), and triage differs, so it earns its own issue. Cost is one new group. Expect `WORLDMONITOR-YD` (`queue-overflow`, 85 events / 81 users) to fall and `timeout` with `raced: true` to rise. That is the fix working: a parked page previously shed everything for the rest of the session and now drains one write per ~25s. ## Verification - **Red first.** Before the fix, the native-path test got past `assert.equal(calls, 1, 'aborting a transport that discards the signal cannot drain the queue')` — the park reproduces. This is what the issue asked for: every pre-existing fixture (`rejectWhenAborted`) rejects the instant the signal fires, so it can only confirm the signal was *attached*. - **Mutation sweep — 10 mutants, 0 survivors.** Drop the race; race only the headers; drop `raced` from the conversion retry policy; drop it from the durable marker; grace period to 0; never tag the deadline error; unbounded deadline; drop the raced alert exemption; route raced through the aggregate-first path; let the noise latch suppress raced reports. - 58/58 in the collector suites, 401 tests across analytics-dependent files, 3 DOM tests, `tsc --noEmit` and biome clean. ### What adversarial review changed (second commit) Two defects, each found independently by two reviewers, both fixed here rather than deferred: 1. **The body read was outside the race** — the wedge survived one line past the deadline. 2. **Raced timeouts could not reach Sentry** — the three gates above. Plus: `LATCH_RELEASE_GRACE_MS` 1s -> 5s, because the ordering argument assumes wall-clock separation that a long task or Chromium's intensive throttling (setTimeout clamped to ~1/min after 5 min hidden) does not provide; and the module-local `withTimeout` renamed to `withCollectorDeadline`, since `src/utils/with-timeout.ts` exports an unrelated `withTimeout` with a different contract. ### Two things I changed in *existing* tests — look here first 1. The retry-policy tests asserted "no retry scheduled" as `assert.deepEqual(fakeTimers.timers, [])`, which the new per-dispatch deadline breaks. They now filter through `retryTimers()` — deliberately a **denylist** of transport-deadline delays, not an allowlist of the retry ladder, so an unexpected timer still reads as a retry and fails rather than being silently filtered away. The ladder caps at 2 attempts (1s, 2s) and cannot collide with the excluded delays. 2. The queue-overflow test parks the transport with no fake-timer harness, so the new deadline held a real timer open and pushed the suite 1.4s -> 22.8s. Fixed in that harness, not by weakening production. ## Known limitations and accepted trade-offs - **`raced` is a timing inference, not a fact.** A false positive is unlikely but possible under hidden-tab timer throttling, and would cost a legitimate write its retry *and* its marker. The inverse cannot happen — only this module's deadline sets the marker. Documented on the type. - **Conversion loss vs. double-count.** Clearing the marker on a raced conversion means one that never committed is permanently lost. That is the deliberate trade, and it matches the precedent this module already set for 502/504 ("may follow a committed row, so the marker must not survive to replay it"). Flagging it because it is a *new* class of silent loss: if you'd rather bound the duplicate risk instead, the alternative is a one-shot replay flag on the marker. - **Concurrency can now exceed 1.** Releasing the latch cannot cancel the abandoned request, so on a parked page the next write dispatches while the previous is still on the wire — the umami#4183 contention this module serializes to avoid. Inherent to the chosen fix, not incidental. - **No backoff once parked.** Every subsequent write on a parked page pays the full deadline. Deliberately not added here (adaptive timeouts are a feature, not this hardening). - **The cross-user aggregate is still `raced`-blind** — `api/analytics-health.js` reconstructs the payload as `{cohort, writes, failures, failureKind}` and drops unknown fields, so making it raced-aware is a two-surface change. Belongs with #6289. - **The module is 1155 lines**, past the 1k threshold (it was 1002 before this). Extracting the deadline helpers is a good follow-up; doing it inside a hardening fix would make this unreviewable. ## Scope `src/app/refresh-scheduler.ts:57-63` has the identical latch shape and the issue names it. Left alone on purpose — #6288 argues the house style is insufficient *for the serialized collector queue specifically*. Filed separately, because its documented standing rule ("every network call reachable from a `scheduleRefresh` callback must carry `signal: AbortSignal.timeout(...)`") is precisely the request-side mitigation this PR shows is not sufficient against a signal-discarding wrapper. Closes #6288 https://claude.ai/code/session_01YECZrKuvpgqGZ3R8UJJA66
…ard able to fail Review findings on #6695. The feature worked for aviation_closure but shipped nothing for the airports that needed it most, and the test could not tell. Coordinates never resolved for notam_closure (P1). The backfill read src/config/airports.ts at runtime, which does not exist in the deployed container -- Railway builds this cron from a scripts-rooted Nixpacks source (startCommand `node seed-aviation.mjs`, watch paths `scripts/**`). Production logs the ENOENT every tick: [Bootstrap] failed to parse src/config/airports.ts: ENOENT: no such file or directory, open '/src/config/airports.ts' -- falling back to seeder AIRPORTS only The loader negative-caches [] for the whole process, so the fill was permanently undefined and 27 of 61 NOTAM-reachable airports published with no lat/lon -- JFK, LAX, ATL, ORD, DFW, DEN, SFO, and Beirut, Damascus, Baghdad, Tehran, Bandar Abbas among them. aviation_closure was unaffected: all 48 AviationStack rows already carried inline coordinates, so it never needed the fill. Fixed by giving every NOTAM-reachable AIRPORTS row inline lat/lon (25 copied verbatim from src/config/airports.ts) and dropping the config-file read from the notify path entirely. That also removes the resolution fallbacks that were dead by construction: both publishers always pass a row, and closedIcaos is filtered to NOTAM_LIST (itself derived from AIRPORTS), so AIRPORTS.find always hit. BND/OIKB (Bandar Abbas) and GBE/FBSK (Gaborone) had coordinates in neither registry, so they would have stayed unlocated even with packaging fixed. Added from Wikipedia airport infoboxes: OIKB 27.21806N 56.37778E, FBSK 24.55528S 25.91833E. The repo's Bandar Abbas city coordinate is 12 km from the airport, so it was not a usable substitute. The wiring guard could not fail (P1). Its `[\s\S]{0,800}` gap reached well past the payload object, so moving a spread out of `payload:` to a sibling key -- where it publishes nothing -- kept all 10 tests green. Verified by relocating the spread and re-running the suite. The guard now extracts the payload object literal and asserts inside it, and pins the resolver call separately. Also: - airportNotifyLocation moved to scripts/lib/, matching weatherAlertNotifyLocation in scripts/_weather-alert-select.mjs (#6694), which publishes the same flat lat/lon keys for weather_alert. - typeof before isFinite, the rule that sibling already documents: Number(''), Number([]), Number(null) and Number(false) all coerce to a finite 0, which is the Gulf-of-Guinea coordinate this guard exists to suppress. Confirmed against the old helper: {lat:'', lon:113.9} published {lat:0, lon:113.9}. - Both publishers now warn when they publish an unlocated closure, mirroring the adjacent countryCode miss-warn -- the silence is why the gap above shipped. - The 0,0 comment no longer presents FAA envelopes as the live hazard: those alerts go to Redis via runFaaSideCar and never reach a publisher. The guard is defensive, and stays. - New registry tests: every NOTAM- and AviationStack-reachable airport must resolve to coordinates, and the seeder must stay in coordinate parity with src/config/airports.ts so drift fails CI rather than degrading at runtime. Tests: 18 pass (was 10). Every guard mutation-tested -- spread deleted, spread relocated out of payload on both publishers, helper returning {}, typeof guard reverted to Number(), 0,0 guard removed, Bandar Abbas coordinates removed, JFK drifted from config, unlocated-warn removed: all 9 go red, suite green unmutated. Sibling contracts unaffected: notification-relay payload-audit, coalesce-key and country-scope-5359 pass 60/60. Claude-Session: https://claude.ai/code/session_016pxwk2YGYRsJBZv75ZdAs3
…der floor #6701 shipped the alarm for #6698 and recorded a P2 residual: the ladder's "honor the advertised provider floor" branch reads only `retry-after-ms` and `retry-after`, but Dodo's API reference documents neither on a 429 — it documents `X-RateLimit-Reset`. If Dodo does not also send `Retry-After`, that branch never engaged in production and every ladder wait was pure jitter. The ladder now reads `X-RateLimit-Reset` as a fallback behind both Retry-After forms, which stay authoritative: Retry-After is a directive to wait, the reset only reports when the window rolls over. Dodo does not publish the header's unit, and it is genuinely ambiguous — the IETF draft's RateLimit-Reset is delta-seconds while this repo's own API emits X-RateLimit-Reset as epoch-milliseconds (server/_shared/api-key-rate-limit.ts). Rather than assume one, all three encodings are disambiguated by magnitude: below 1e9 is delta-seconds (a delta that large is ~31 years), 1e9-1e12 is epoch-seconds, 1e12 and above is epoch-milliseconds. These ranges cannot collide in any plausible present. A reset already in the past clamps to 0 so it can never subtract from the jittered wait. Reading an epoch as a delta was the failure mode worth guarding: it yields a decades-long floor that silently disables every retry. Also parses the RFC 9110 HTTP-date form of Retry-After, which previously fell through `parseFloat` to NaN and advertised no floor at all. Verified against the real error shape, not just a fixture: the SDK's 429 is `RateLimitError extends APIError<429, Headers>`, so `.headers.get()` is a real Headers instance on the production path. Tests: 5 new cases (each encoding, past-reset clamp, precedence, HTTP-date, unparseable/negative). Confirmed red before implementing. Mutation sweep: 8/8 mutants killed, 0 survivors — including epoch-read-as-delta and inverted precedence. Full convex suite 1240/1240 across 57 files; tsc, biome, markdownlint, lint:public-docs, docs:check all clean. Known unknown, documented in the runbook: whether the reset describes Dodo's burst (40/s) or sustained (240/min) window. If it reports a sustained reset up to 60s out while the real block is a per-second burst, honoring it as a hard floor ends the ladder early and turns a rescuable checkout into a terminal 429. The #6701 alarm is what will detect that — watch for the rate rising. Refs #6698 Claude-Session: https://claude.ai/code/session_01DZ1r3aEAzeKVendjHMyyPB
…ools get_news_intelligence gains `query` and `min_importance`; get_news_clusters gains `query`. Both narrow the LIVE news window — they are not a historical index, and the parameter descriptions say so and point at search_intel_history. Two behaviours are load-bearing and covered by tests proven to go red against the wrong implementation: - Narrowing runs BEFORE the cap. Capping first takes the head of the raw list and matches within it, so a match past the cap silently disappears and the tool reports "no results" for a story it holds. - `min_importance: 0` is a real floor. argNum returns 0 for an explicit zero and null for absent, so the guard is `!== null`; a story carrying no score fails the floor rather than being coerced to 0 and passing. Cluster matching also spans member headlines, not just the primary: the primary is recency-picked, so the searched term often sits on a sibling headline. Refs U2, R11, R12, AE6. Claude-Session: https://claude.ai/code/session_01SBwGef6aBZZZm57r9uWVmB
…ath (#6706) `checkUserPrefsWriteRateLimit` ended every preference write with a stale-window cleanup keyed on `userId` alone. Convex derives a mutation's OCC read set from the index ranges it scans, so that one unbounded query widened the read set from the single current-window counter to the caller's entire row set across all windows. A second concurrent write by the same user — two dashboard tabs, or a dragged slider persisting per change — invalidated it, and because the contending writes kept arriving the retries collided too. Convex exhausted its retries and the write failed outright (WORLDMONITOR-ZE). The sweep is opportunistic GC with no reader, so it moves to a cron: - `checkUserPrefsWriteRateLimit` now only ever scans `(userId, currentWindowStart)`. - `pruneStaleWriteRateLimits` (hourly, 52 past) deletes rows below the current window start in bounded batches that self-drain. The cutoff is derived, not operator-overridable — a row at or above it is a live counter and dropping one would hand that user a fresh budget. - New `by_windowStart` index so the sweep is a range scan whose read set is disjoint from every live counter row. Regression coverage records the index ranges the real limiter opens against the real convex-test database and asserts every one is bounded to the current window; it fails on the pre-fix code. Mutation-tested: `lt` -> `lte` on the cutoff, dropping the batch floor, and `>=` -> `>` on the self-drain condition are each killed. Closes #6706 Claude-Session: https://claude.ai/code/session_01NotV3tBLmtYQrcswVY9Knn
… it as a date
Self-review of the previous commit found a defect it introduced. Adding the
RFC 9110 HTTP-date branch to `retryAfterHeaderToMs` let a numeric value that
failed the `>= 0` check fall through to `Date.parse`, which is not inert:
V8 parses "-5" as a real date (2001-05-01) and "-0.5" as one too. The stale
timestamp then clamped to 0, so an invalid negative header reported "wait
zero" instead of "nothing advertised".
The ladder happens to treat 0 and null identically (`retryAfterMs ?? 0`), so
this was inert today — but it is a trap for any future caller that
distinguishes "no floor advertised" from "a zero floor", which is exactly the
distinction the function's return type promises.
A value that parses as a finite number is now decided entirely on the numeric
branch: non-negative becomes a delta, negative returns null. The date branch is
reached only when the header is not numeric at all. No real HTTP-date is lost —
all three RFC 9110 date forms (IMF-fixdate, RFC 850, asctime) begin with a day
name, so none is numeric-leading.
Test pinned red first ("expected +0 to be null") before the fix. Mutation sweep
re-run at 9/9 killed including a mutant that restores the fall-through.
Full convex suite 1241/1241 across 57 files at --maxWorkers=2. Note: the
default worker count over-subscribes this worktree and produces unrelated
timeout failures in companyMonitoring/poolSelection, which pass in isolation
and do not import this module; constrained workers are green.
tsc, biome, markdownlint clean.
Refs #6698
Claude-Session: https://claude.ai/code/session_01DZ1r3aEAzeKVendjHMyyPB
Reports what WorldMonitor draws on and how far to trust it, from the committed attribution manifest and source-tier registry. No network, no cache. Two populations, deliberately NOT merged. `providers` are upstream hosts keyed by host (acleddata.com) with licence and attribution status; `outlets` are named news organisations keyed by masthead (Reuters) with editorial tier and the same provenance block the news tools attach to stories. Only 3 of 536 active provider records share a key with the outlet table, so presenting one joined inventory would mean inventing attribution for the other 533 — the exact dishonesty this tool exists to argue against. Correctness points, each with a test proven to go red against the alternative: - An undeclared tier reports null, never the 4 that getSourceTier() defaults to. A defaulted number is indistinguishable from a declared one. The record builder is exported because the outlets view enumerates the tier table itself, so no name it returns could exercise the branch — an unobservable guard is a vacuous one. - Excluded manifest rows are surfaced as a count rather than silently dropped. - Enumerated views report matched alongside returned; the full 249KB provider inventory cannot fit one response, so summary is the default and truncation is always visible. Refs U3, R13. Claude-Session: https://claude.ai/code/session_01SBwGef6aBZZZm57r9uWVmB
The agent-readiness scanner reads server-card.json's tools[] for pre-connection preview, and a guard asserts it mirrors TOOL_REGISTRY exactly. Adding get_sources left the card at 63 against a registry of 64, so a scanner would have previewed a stale inventory. Regenerated with the command the guard documents. Claude-Session: https://claude.ai/code/session_01SBwGef6aBZZZm57r9uWVmB
#6678) #6577 asked for three things. #6587 delivered the behaviour fix and explicitly deferred the migration, because `Panel.setContentNodes` did not exist on `main` when it was written. #6557 has since landed, so this finishes the third item. Each panel's SUCCESS write now commits through the sanctioned helper — `setTrustedContent` for GivingPanel, which builds its own markup string and so cannot use the DOM-node path. The LOADING branches deliberately stay on `replaceChildren`: `setContentNodes` clears through `clearErrorState()`, and resetting `retryAttempt` on a loading paint would flatten the exponential backoff ladder to its 15s floor. The migration is RUNG-NEUTRAL. Every one of these panels already called `clearErrorState()` on its success path (#6587), so the chip / countdown / backoff behaviour is unchanged by this commit. What the helpers add is the `_locked` bail BEFORE the write, `cancelPendingContentWrite()` and `invalidateCommittedHtml()`. Only the bail is observable per call site, and it is the one with a user-visible failure mode — a success render painting over the upgrade CTA. `tests/dom/panel-content-write-6678.test.mts` proves it at each of the six migrated writes; all six fail before this change. That file also closes a gap this change WIDENS. Three panels now have exactly one `replaceChildren` left — the loading branch — so their ratchet entries read `x1`, which reads like an unfinished migration inviting someone to drive it to zero. `panel-error-latch-6577` pinned that hazard only for TechEvents (it says so itself: "a base-class contract test riding on a convenient subclass"). The new file adds the per-call-site cases for ServiceStatus and DefensePatents; both were confirmed to fail when their loading branch is migrated, reporting the flattened ladder as `Retrying... (15s)` where 30s was due. GdeltIntelPanel's `insertAdjacentElement` is deliberately NOT migrated. It inserts a SIBLING before `this.content`, never a child, so it cannot latch the header chip, and the sanctioned helpers WIPE content — they would destroy the articles the summary sits above. The guard's own doc already tracks it as `positional` for inventory completeness; its allowlist entry stays. `tests/giving-panel-expiry.test.mts` stubs `Panel` for its esbuild harness, so the stub gains `setTrustedContent` — modelling the clear as part of the write, which is why the panel no longer calls `clearErrorState()` separately. Ratchet: 43 -> 41 entries, 97 -> 91 call sites, 28 -> 27 files. Claude-Session: https://claude.ai/code/session_012w3T1cwt4kcs49ekURAk2H
An agent could previously see 64 tools and call none of them without a
subscription. get_sources is now callable with no credentials at all, so an
agent can learn what WorldMonitor covers — and how far to trust it — before
anyone signs up.
This narrows a real invariant ("everything returning DATA requires
credentials"), so it is bounded three ways, each with a test proven to go red
when the guard is removed:
- The roster IS the registry's `_freeTier` flag. No second list, and the
tools/list access marker is derived from the same flag, so what is advertised
cannot drift from what is authorised.
- dispatchToolsCall re-checks the flag. Promotion (handler) and authorisation
(dispatch) are deliberately not the same line of code, so widening the
handler's matching by mistake cannot silently expose gated data.
- The free path takes its own per-IP ceiling that fails CLOSED. The discovery
limiter beside it fails open, justified in its own comment by carrying no
data — a justification that does not survive a tool returning real data. An
unreachable or unconfigured limiter refuses.
A free caller is modelled as an explicit `{ kind: 'free' }` principal rather
than a synthesised env_key/pro, which forced the compiler to surface two arms
that were wrong by default: setUsageContext would have labelled free traffic
`enterprise_api_key` in Axiom (corrupting the dataset the tier is measured by),
and buildAuthHeaders would have HMAC-signed an anonymous caller as pro. Both
now handled explicitly; signing throws.
Roster is deliberately one tool. get_sources is a committed-registry read with
no staleness mode — a data tool whose seed ran late would hand an
uncredentialed caller an empty envelope, which reads as a dead server and
defeats the point. Widening needs production freshness evidence, not an
assumption; the mechanism is generic and adding a screened tool is one line.
Refs U7, R7, R9, R15, R6, AE1, AE2.
Claude-Session: https://claude.ai/code/session_01SBwGef6aBZZZm57r9uWVmB
…ath (#6706) (#6709) Closes #6706 (`WORLDMONITOR-ZE`). ## The bug `checkUserPrefsWriteRateLimit` ended **every** preference write with a stale-window cleanup keyed on `userId` alone: ```ts .withIndex("by_user_window", (q) => q.eq("userId", userId)) // no windowStart ``` Convex derives a mutation's OCC read set from the index ranges it scans, so that one unbounded query widened the read set from *the single current-window counter* to *the caller's entire row set across all windows*. A second concurrent write by the same user — two dashboard tabs, or a dragged slider persisting per change — invalidated it, and because the contending writes kept arriving, every retry collided too. Convex exhausted its automatic retries, so this was a **write that actually failed**, not one that was merely delayed. ## The fix The sweep is opportunistic GC with no reader, so it moves off the user-facing path entirely — option 3 in the issue, the shape it calls durable. - **`checkUserPrefsWriteRateLimit`** now only ever scans `(userId, currentWindowStart)`. Its read set is the one counter row it accounts against. - **`pruneStaleWriteRateLimits`** (new `internalMutation`, hourly cron at :52) deletes rows below the current window start in bounded 500-row batches that self-drain. The cutoff is **derived, not operator-overridable**: a row at or above it is a live counter, and dropping one would hand that user a fresh budget — a limiter bypass, not early GC. The only knob exposed is batch size, with a floor of 1 so `limit: 0` cannot become an empty reschedule loop. - **New `by_windowStart` index** so the sweep is a range scan whose read set is disjoint from every live counter row, instead of a full table scan that would collide with all of them. Hourly rather than daily: a user writing continuously produces one row per 60s window, so an hourly tick bounds the table at ~60 rows per active user instead of ~1440. ## Acceptance criteria | From the issue | Status | |---|---| | Write path's OCC read set no longer includes rows outside the current window | Enforced by test — see below | | Regression test at the Convex layer | Added (read-set observation; `convex-test` has no OCC simulation, so a two-mutation race cannot be driven there — the read set itself is the observable that decides the conflict) | | `WORLDMONITOR-ZE` quiet for a day after deploy | Post-deploy, tracked on the issue | ## Testing `npm run test:convex` → `convex/__tests__/userPreferences.test.ts` 13/13, stable across 3 consecutive runs. The regression test wraps the **real** `convex-test` `ctx.db` and records the index ranges the **real** limiter opens, delegating every read and write through to the real database — there is no second implementation of the limiter's storage, so it cannot pass for a reason production would not also produce. It asserts every range against `userPreferenceWriteRateLimits` is bounded to `(userId, currentWindowStart)`. Proven RED on the pre-fix code before the fix landed: ``` AssertionError: expected [ [ 'eq', 'userId', …(1) ] ] to deeply equal [ [ 'eq', 'userId', …(1) ], …(1) ] - [ "eq", "windowStart", 1699999980000 ] ``` **Mutation-tested** — each of these mutants is killed: | Mutant | Killed by | |---|---| | `lt` → `lte` on the prune cutoff | `collects expired windows and never the live counter`, `self-drains across runs` | | Drop the batch floor (`Math.max(1, …)`) | `a zero or non-finite limit falls back instead of rescheduling forever` | | `>=` → `>` on the self-drain condition | `self-drains across runs`, `a zero or non-finite limit…` | | Restore the unbounded sweep | Both read-set tests (the original RED run) | One existing test changed contract deliberately: `consolidates duplicate counter rows left by concurrent first writes` asserted the write path *deletes* an older-window row. It now asserts the current-window duplicates are still consolidated (count 4, not 103 — the expired window's 99 is neither folded in nor deleted) while the expired row survives until the prune collects it. That survival *is* the fix. Other gates: `npm run typecheck` green, `npm run lint` green (`biome check` clean on all 4 changed files), `tests/convex-entrypoint-hygiene.test.mjs` 4/4. Full-suite note: `companyMonitoringAccountLifecycle.test.ts > dense destructive purge continues across the bounded 93-company page` fails with a 5s timeout on this machine. Verified **pre-existing** — it fails identically on `origin/main` with all four of my files reverted. Not touched by this change. ## Deploy note Adding `by_windowStart` triggers a Convex index backfill on this table. The table is small and the index is only read by the cron, so no user-facing path depends on the backfill completing. Rows accumulated before this deploy are cleared by the first tick's self-draining chain. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) (Opus 5, 1M context) https://claude.ai/code/session_01NotV3tBLmtYQrcswVY9Knn
…te (#6678 review) Review round on the #6678 migration. Three independent reviewers — two of them on different model families — converged on the same gap, so it is fixed rather than just recorded. GdeltIntelPanel.renderTopicSummary is the one write #6678 deliberately did NOT migrate: it inserts a SIBLING before `this.content`, so the wiping helpers would destroy the articles it sits above. But staying off the helper also costs the `_locked` bail, which left it as the panel's only lock-blind content write — `showLocked` sweeps header->content siblings ONCE, at lock time, so a summary inserted afterwards paints a tone/volume sparkline above the "Upgrade to Pro" CTA. Exactly the leak the migrated writes now refuse. It now mirrors that sweep by hand, keyed on the `panel-is-locked` class because `_locked` is private with no protected accessor. `unlockPanel` re-shows every sibling in the same range, so the hide clears itself instead of stranding the summary. Pinned by a new case that also covers the unlock half; the case fails when the guard is disabled. Latent either way — `gdelt-intel` is not in WEB_PREMIUM_PANELS — so this closes the contract, not a live leak. Also from the review: - `tests/dom/panel-error-latch-6577.test.mts` header prose described the superseded mechanism ("they must call the public `clearErrorState()` themselves"), which none of the five panels does any more. A contributor reading it for "the rule" would have been pointed at the idiom this migration removes. Rewritten to record the supersession and point at the new file; its assertions were verified still non-vacuous (they drive the real render chain, so a broken clear inside the helper still reds them). - The two loading-rung cases asserted the rung survived but never that the loading render painted, so DELETING a loading branch outright would have left them green. They now assert the loading markup landed. - The giving-panel-expiry `Panel` stub gains the `_locked` bail so it models the real helper's order (bail, then clear, then write) rather than only the second half. Note for the next editor: no backticks in comments inside that stub — it lives in a template literal and they terminate it (cost one red run here). - The guard's `stale` remediation text said "delete the line", which is the wrong advice when the vanished write was a loading branch. It now names the hazard and the tests that catch it first. - GivingPanel's doc comment had been stranded above the wrong describe block by the loading-rung insertion; moved back to its own. Verification: DOM suite 37 files / 344 tests, node:test 25/25, typecheck, biome, safe-html, and the content-write guard all clean. Claude-Session: https://claude.ai/code/session_012w3T1cwt4kcs49ekURAk2H
…urface
CI's docs-stats job failed on the get_sources addition. The generated snapshot
is written by the BARE script invocation, not by --check; --check only
validates the hand-maintained prose that quotes the number, so running it alone
reports OK while docs/generated/stats.json stays stale.
Regenerated the snapshot (mcpToolCount 63 -> 64) and updated all 23 prose sites
the check names, each by its own matched pattern rather than a blanket
find-replace, so an unrelated 63 in the same file is never touched.
Also updated two families the gate does NOT check and which therefore drift
silently: the docs/zh mirrors and all 28 pro-test locale files, including the
`s12v` stat value whose paired label is "MCP tools". Those needed per-language
handling — Chinese puts a measure word between the count and the noun
("63 个实时工具"), while Swahili and Thai put the number after the noun
("Zana 63 za MCP"), so no single adjacency rule reaches them.
Two 63s were deliberately left alone and are not tool counts:
`^[a-z0-9][a-z0-9-]{0,63}$` in docs/zh/finance-data.mdx is a regex quantifier,
and 63% in docs/zh/signal-intelligence.mdx is a percentage.
Every changed line contains 63 or 64 — no unrelated churn from the regen.
docs-stats, source-attribution, and product:facts:check all clean.
Claude-Session: https://claude.ai/code/session_01SBwGef6aBZZZm57r9uWVmB
Editing pro-test/src/locales/*.json invalidates the committed bundle: Vercel serves the bytes in public/pro/, and the pre-push hook gates their freshness. The rebuild is the 63 -> 64 tool count reaching the rendered page, plus the content-hash filename churn that always follows. welcome.html and index.html change only in their asset references. Claude-Session: https://claude.ai/code/session_01SBwGef6aBZZZm57r9uWVmB
…6732) * fix(panels): clear error state before the lock bail; expose isLocked * Address review: assertion messages belong in expect(value, 'why') toBeNull/toBe/toBeGreaterThan take no message argument; the five flagged assertions move their rationale into expect()'s second parameter, matching how the rest of the file explains itself. --------- Co-authored-by: Elie Habib <elie.habib@gmail.com>
… exiting green (#6845 item 2) (#6872) When the volume file, the local file and R2 are all unavailable, the fallback restored the freshness marker from the active version with a DEEP validation walk and exited 0. The restored marker carries the active version's own timestamp, so once that data passed its interval the section was due again next tick and took the same path: a ~250-round-trip member walk, every day, exiting green, with the data never refreshed and the tick indistinguishable from progress. Two changes: - The fallback validates shallowly. The corpus was validated when it was published, no new data arrived, and re-walking ~125k members daily proves nothing about freshness. - Data already past the section interval (mirrored from seed-bundle-static-ref) exits GRACEFUL_FETCH_FAILURE_EXIT_CODE with a distinct warning, so the runner records GRACEFUL_FAIL and the tick is distinguishable from progress. Fresh data still exits 0 quietly. exitCode rather than process.exit(): the keep-alive sockets behind the Redis REST client need to drain, and tearing them down synchronously trips libuv assertions on some platforms.
…#6890) Both rules pointed at @SebastienMelki, so every PR touching api/api-route-exceptions.json auto-requested a review from him at open (#5998: request stamped 2026-08-01T18:08:49Z, identical to the PR's createdAt). GitHub attributes the auto-request to the PR author, which made it read as a manual self-assignment. Deleting the rules was the other option and it is wrong. The manifest is the escape hatch OUT of the sebuf gate: per scripts/enforce-sebuf-api-contract.mjs:5-10 a file under api/ passes by being a real gateway or by being listed here, and the gate only validates an entry's shape, never whether the exception is justified. Same for the enforcement script -- it cannot police edits to itself. Those are precisely the two things CI cannot cover, so the human check stays. Pointing both at the repo owner keeps the guard where it carries information and drops it where it does not: GitHub never requests review from the PR author, so an owner-authored change is silent, while a change from anyone else still lands a request. A code owner reviewing their own PR was a no-op guard. Sebastien keeps write access; only the auto-ping target changes. Claude-Session: https://claude.ai/code/session_01MW1bTKejzAhV46N3w7WtUX
…members (#6889) * feat(seeders): one rotating heavy bundle instead of three static-ref members Splits static-ref by cost rather than provisioning a Railway service per job. #6874 added seed-bundle-arms-suppliers and seed-bundle-military-bases as 1-section siblings, each carrying one low-cadence member. Neither was ever provisioned. This replaces both with ONE bundle carrying all three heavy members, and removes them from leftover. The 2026-08-18 03:00 tick is the evidence. Container boot 71s, Arms-Suppliers 371s, Submarine-Cables 22s -> 393s of a 570s budget consumed, and Mineral-Production's 190s reservation missed by 13 SECONDS on the exact tick its acknowledgement expired. The #6807 concurrency fix did work (547s -> 371s); it just could not help, because the ordering question survived it: a member that never publishes never stops being due, so Arms-Suppliers led every tick and spent the budget it freed. Why one service, not three. Railway kills a cron container at 10 minutes, so maxBundleMs 570_000 is a platform ceiling and no arrangement runs Arms-Suppliers (460s worst case) and Military-Bases (410s) in the same tick. But "cannot share a tick" is not "cannot share a bundle" -- the runner defers the loser to the next daily fire, and at 10/30/60-day cadences a one-day deferral costs nothing. Railway caps a project at 100 services and the fleet is at 81. The lead slot rotates and that is load-bearing, not cosmetic. A fixed order hands the permanently-due member the first slot forever, reproducing inside the new bundle the exact starvation that made it necessary. Rotating dayIndex % 3 gives each member the lead every third tick, so a permanently failing member consumes at most one lead slot in three. Uses days-since-epoch, not getUTCDay(): %3 of a 7-day clock yields Sat=0 and Sun=0, two consecutive lead days for one member. seed-bundle-macro.mjs uses the same device for the same reason. What leftover gains: its four remaining members reserve 470s of 570s IN TOTAL, so every due member is admissible on every tick regardless of what else ran. The bundle no longer has an ordering question at all. tests/bundle-budget- admission.test.mjs now asserts that property rather than pinning a membership list, so moving an expensive member back fails the gate. First tick after provisioning is 2026-08-19 04:00 at rotation offset 2: Military-Bases -> Mineral-Production -> Arms-Suppliers. Both blocking alarms clear on that single tick; Arms-Suppliers defers to the 20th. Verified by executing the real file with a stubbed runBundle -- today it prints offset 1, matching the calendar by hand. Acknowledgements: the two per-sibling entries collapse to one for the consolidated heartbeat, which also retires the shared-issue carve-out in the baseline gate -- every acknowledgement owns its own tracking issue again. mineralProduction is re-anchored to the same first tick. 658 tests pass across 12 suites. Biome clean. Registry, watch-path closure, runbook and health-endpoints docs all updated; _defense-industrial-source.mjs stays in BOTH closures because both SIPRI seeders import it. Refs #6806, #6845. * fix(docs): retire the second bundle-tick probe from the documented health total Collapsing armsSuppliersBundleTick and militaryBasesBundleTick into one staticRefHeavyBundleTick took /api/health from 274 probed keys to 273, and docs-stats --check reds on the four docs that pin the number. Driven from the checker's own output rather than a hand-kept list: it names the file, the claimed value and the real one, which is why all four files (both locales, both pages) get corrected in one pass instead of the two I had touched. `ok` moves with `total` — the sample is a fully-healthy response, so leaving ok at 274 beside total 273 would document an impossible payload. docs:check now reports OK; 1002 docs/i18n/mdx-lint tests pass.
* feat(canada): add BC evacuation alerts * fix(canada): close union health, last-good, and BC fail-closed gaps Review fixes for #6659 / PR 6884: point canadaAlerts health at the union seed-meta, add an Alberta sibling probe, fail closed on unknown BC statuses, preserve last-good union data when a province snapshot is missing, prefer the Alberta sibling then the legacy key during cutover, and cap the published union at 200 alerts. Co-authored-by: Elie Habib <koala73@users.noreply.github.com> * test(canada): allow #6659 to own the three provincial-alert EMPTY acks The union probe move and the Alberta/B.C. sibling rows share one first Railway tick, so they use the same owner issue as the existing #6806 bundle-tick exception. Co-authored-by: Elie Habib <koala73@users.noreply.github.com> * fix(canada): honor TTL extend failure and keep long-running BC alerts visible Last-good union preservation now requires a confirmed EXPIRE. Union seed-meta writes use the non-throwing helper so a bookkeeping SET cannot fail the province publish. DeckGL times canadaAlerts by updatedAt so EVENT_START_DATE older than the default 7d window does not hide active evacuations. Co-authored-by: Elie Habib <koala73@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
…to setContentNodes (#6742) * refactor(panels): migrate CountersPanel and InternetDisruptionsPanel to setContentNodes * Address review: drop the now-unused replaceChildren import * docs(panels): correct the render() call-graph claim on InternetDisruptions The migration comment said render() is reached only from setOutages/setDdos/ setAnomalies, 'so each call is a proven success'. The constructor also binds a delegated [data-tab] listener that calls render(), so a tab click runs clearErrorState() with no new data. The behaviour is safe, but for a reason the comment did not give: showError() replaces this.content, so no tab button survives for the user to click while the chip is set. Verified both halves against the panel - a data render produces clickable [data-tab] buttons and showError() removes all of them. State the real invariant and the condition that would break it, so a future retry-callback wiring does not silently start flattening the backoff rung on a tab click. Co-authored-by: Elie Habib <koala73@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
…tentNodes (#6758) * refactor(panels): migrate GoodThingsDigestPanel stub render to setContentNodes * Address review: ratchet GoodThingsDigestPanel setTrustedHtml x3 -> x2 The stub-render rewrite replaced both the setTrustedHtml(this.content, '') clear and the appendChild(list) commit with one atomic write, so the recorded count for that idiom drops by one. * fix(panels): stop summarizing into detached cards when the digest is locked setContentNodes bails on a locked panel, so the stub list is never attached - but cardElements still held the detached cards and the Promise.allSettled batch kept writing summaries into them. Its only liveness guard is this.element?.isConnected, which stays true while locked. Drop the handles and return, matching the cardElements reset the empty branch already does. Latent today: 'digest' is in neither WEB_PREMIUM_PANELS nor WEB_CLERK_PRO_ONLY_PANELS, so the bail cannot fire yet. Verified by mutation: without the guard cardElements keeps 5 detached cards after showLocked(); with it the count is 0 and generateSummary is never called. Co-authored-by: Elie Habib <koala73@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
…6750) * refactor(panels): migrate PositiveNewsFeedPanel to setTrustedContent * Address review: drop the now-unused setTrustedHtml import --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
…ndow (#6877) * fix(consumer-prices): admit the US drinking-water pack in its quantity window Raise the US water item window so 24×16/16.9 fl oz falls inside min/max, and guard every basket item whose canonical name parses to a quantity against the same contradiction (#6869). Co-authored-by: Elie Habib <koala73@users.noreply.github.com> * fix(consumer-prices): harden US water quantity guard --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
* fix(a11y): shared focus trap + focus restore for modal overlays
aria-modal="true" promises assistive tech that content behind the dialog
is inert, but ~12 overlays neither trapped Tab nor restored focus on
close - keyboard focus walked straight into the dashboard behind an
'inert' overlay, and closing a dialog dropped focus to <body>.
- New src/utils/focus-trap.ts: createFocusTrap(container, { onEscape?,
initialFocus? }) with activate/deactivate, modeled on the existing
per-surface implementations (confirm-dialog, market-chart-interactions,
CountryDeepDivePanel). Captures the opener on activate and restores it
on deactivate; ignores Tab while focus is inside a stacked dialog (e.g.
confirm-dialog over the settings modal) so nested overlays keep their
own trap.
- Adopted in: SearchModal, SignalModal, StoryModal, watchlist modal,
UnifiedSettings (also gains aria-modal), MobileWarningModal (also gains
Escape - it had no keyboard dismiss at all), WidgetChatModal and
McpConnectModal (both also gain role=dialog/aria-modal/aria-label;
McpConnectModal had no Escape either), LiveNewsPanel channel manager
(gains role=dialog), and the mobile menu / region bottom sheet.
- Mission preset popover now returns focus to its trigger on close
instead of dropping it to <body>.
The three existing hand-rolled traps are intentionally left untouched;
consolidating them onto the shared utility can follow separately.
* test: update modal harnesses for shared focus trap
* fix(a11y): give the top-most focus trap sole ownership of Tab and Escape
Three problems in the shared trap's document capture handler, all cases of a
trap acting for a container it no longer owns:
- Every active trap ran its Tab branch whenever focus sat on <body>, so with
two overlays open the earliest-registered (bottom-most) one won and pulled
focus into the overlay behind the visible dialog. Traps now register in a
module-level stack and only the most recent one acts.
- The Escape branch ran before the containment check and called
stopPropagation(), so it could close a background dialog and swallow the key
before the front dialog saw it. It now takes the same ownership guard as Tab.
- A container with no visible focusable (hidden or detached by a teardown path)
preventDefault()ed every Tab, leaving the whole page with no reachable focus
target. It now lets Tab through, matching market-chart-interactions.ts.
Also folds the trap's private focusable-element filter into the canonical
getFocusableElements() in dom-utils, whose docstring already claimed the job.
The two had drifted: the canonical one omitted form controls, so the selector is
widened to keep SearchModal's input and MobileWarningModal's checkbox in the
cycle. That also brings ProActivationInterstitial's brief-hour <select>, which
uses the canonical helper, into its own trap's cycle for the first time.
Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
* test(a11y): cover the shared focus trap contract
The trap ten overlays now depend on had no test, and both suites this PR
touched replace it with a stub -- so a regression in Tab wrap, focus restore,
the stacking guard, or the zero-focusable branch would have shipped green.
Adds 21 cases over the real utility: initial-focus resolution and its
fallbacks, Tab and Shift+Tab wrap at both ends, recovery when focus has fallen
to <body>, deferral to a container the trap does not manage, the empty-container
branch, Escape with and without a handler, stack ownership across two traps and
the handback when the top one closes, and every deactivate() path including the
isConnected-gated restore and restoreFocus: false.
Uses the mini-dom harness rather than vitest/jsdom, which reports
offsetParent === null for every element and would make the focusable filter
return an empty list -- passing every assertion vacuously. mini-dom gains
contains(), which the trap's containment guard needs.
The search harness keeps its stub, which the prior review round accepted: its
overlay is a plain object with no querySelectorAll, so the real trap cannot run
against it. A comment now points at the suite that pins the real contract.
Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
* fix(a11y): release focus traps on teardown paths
UnifiedSettings.destroy() and MobilePrimaryNav.destroy() tore down every other
listener they had installed but left their traps active. Destroying either while
its overlay was open left the trap's document capture listener alive over a
detached or hidden container, so Tab kept being intercepted for the rest of the
page's life.
Both now deactivate with restoreFocus: false, since teardown should not hand
focus back to a control that is going away too. teardownSettings() and
closeMenu()/closeRegion() keep the restoring default for user-initiated closes.
The UnifiedSettings test double gains deactivate() so extending that harness
past open() fails on the behavior under test rather than a missing method.
Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
* fix(a11y): stop background signal notifications from taking focus
SignalModal.show() is reached only from the data loader's periodic correlation
and military-surge analysis (data-loader.ts:3599, :3605, :4079) -- a background
refresh, not a user gesture. Wiring the trap into activateEsc() meant those
pushed focus to the modal's close button and confined Tab, so a user typing in
the search box or a settings field lost the caret mid-keystroke.
show() now takes Escape handling without focus containment. The badge-click
paths, showSignal() and showAlert(), keep the trap: there the user asked for the
dialog, so moving focus into it is the correct behavior.
Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
* fix(a11y): return mission popover focus to the control that opened it
Closing the popover always restored focus to #missionPresetBtn, but that button
sits in .mission-preset-mount, which main.css:1093 hides with
display: none !important below 1075px. focus() on an element in a display:none
subtree is a no-op, so on mobile -- where the popover opens from
#mobileMenuMission instead -- focus still dropped to <body>, the exact behavior
this restore was added to fix.
openMissionPresetPopover() now records the anchor it was opened from and the
close path restores to it, falling back to the desktop trigger when the recorded
opener is gone.
Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
* fix(i18n): translate the Live News channel manager dialog label
The new aria-label was the literal 'Manage channels', so screen-reader users on
every non-English build heard this dialog announced in English while every other
control inside it was translated. components.liveNews.manage already exists in
each locale file and live-channels-window.ts:451 -- the content this modal loads
-- already uses it.
Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
* refactor(panels): migrate MonitorPanel to setContentNodes * Address review: drop the redundant clearChildren before setContentNodes setContentNodes already wipes this.content, and the ratchet entry for this write was removed in the migration commit, so the leftover call failed the panel-content-write guard as an unlisted write. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Elie Habib <koala73@users.noreply.github.com> Co-authored-by: Elie Habib <elie.habib@gmail.com>
* feat: add Parallel Search to MCP Quick Connect
Signed-off-by: georgeatparallel <george@parallel.ai>
* fix(sources): register search.parallel.ai in the attribution ledger
The new MCP preset introduces an outbound host, and every host the
scanner finds under src/ needs a curated manifest row. Without one this
PR is red on two required gates: docs-stats fails outright
("invalid manifest (missing manifest entry for search.parallel.ai)",
via scripts/docs-stats.mjs -> buildSourceAttributionStats), and unit
fails two cases in tests/source-attribution.test.mjs — "source inventory
has complete metadata and matches the generated catalog" and "the
committed manifest is a fixpoint of its own generator".
Registers the host the way every other optional connector in the
catalog is registered: Robtex, Linear, Airtable, and both Cloudflare
MCP hosts all use the excluded "user-configured MCP connector" posture,
so the public provider total stays at 737 hosts / 731 providers,
unchanged from main. Provider identities are hash-pinned, so
PROVIDER_IDENTITY_REVIEW is bumped to the digest recomputed over the
override table, retaining the reviewed B.C. identity already there.
The row cannot land ahead of the preset: validateManifest rejects an
`observed` row whose host the scanner cannot find, and an observed row
requires at least one source reference. The ledger is a fixpoint of the
source tree, so the row and the preset move together.
Verified on this head: sources:check exit 0, docs-stats --check OK,
tests/source-attribution.test.mjs 36/36, tests/mcp-presets.test.mjs
17/17, and the live preset suite 34/34 including Parallel initialize.
Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
---------
Signed-off-by: georgeatparallel <george@parallel.ai>
Co-authored-by: Elie Habib <elie.habib@gmail.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
…ded (#6892) * feat(mcp): add Parallel Search to MCP Quick Connect Adds an optional Parallel Search preset to the MCP Quick Connect catalog. The public https://search.parallel.ai/mcp endpoint answers initialize, tools/list, and tools/call without a provider credential, so the preset carries no authNote and no apiKeyHeader. Verified against the live endpoint: web_search requires exactly `objective` and `search_queries`, which the preset's defaultArgs supply. Existing presets and custom MCP servers are unchanged. The MCP proxy has no host allowlist, so this grants no new network reach — the preset only prefills the connect form with a URL a Pro user could already type by hand. Co-authored-by: George Pickett <georgeatparallel@users.noreply.github.com> * fix(sources): register search.parallel.ai in the attribution ledger Adding an MCP preset introduces an outbound host, and every host the scanner finds under src/ needs a curated manifest row. Without one, `sources:check` exits 1 and tests/source-attribution.test.mjs fails two cases ("source inventory has complete metadata..." and "the committed manifest is a fixpoint of its own generator"), which turns `test:data` red. Registers search.parallel.ai the way every other optional connector in the catalog is registered — Robtex, Linear, Airtable, and both Cloudflare MCP hosts all use the excluded "user-configured MCP connector" posture — so the public provider total is unchanged at 735 hosts / 730 providers. A provider-bearing override is hash-pinned, so PROVIDER_IDENTITY_REVIEW is bumped to the recomputed digest with the reason and review reference for this epoch. The manifest row cannot be landed ahead of the preset: validateManifest rejects an `observed` row whose host the scanner cannot find, and an observed row requires at least one source reference. The ledger is a fixpoint of the source tree, so the row and the preset move together. Co-authored-by: Elie Habib <koala73@users.noreply.github.com> * docs(sources): make the attribution ledger visible where hosts get added The gate that broke #6447 was undiscoverable: nothing in mcp-store.ts hints that adding a serverUrl obligates a manifest row, and CONTRIBUTING never mentioned sources:check. A contributor whose diff is one data object has no path from the file they edited to the ledger that tracks it, and their focused test passes. Documents the obligation at the edit site and in the contributor guide, including the self-scan footgun: source-attribution.mjs is inside SOURCE_ROOTS, so a provider URL pasted into one of its comments or review strings registers that host as a discovered data source. That happened while writing this branch — citing parallel.ai's terms URL in the review reference invented a `parallel.ai` provider row and moved the public total to 736/731. Also records why a preset is a curation decision rather than a capability change: the proxy keeps no host allowlist. Co-authored-by: Elie Habib <koala73@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: George Pickett <georgeatparallel@users.noreply.github.com> Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
…le pass (#6893) military:arms-suppliers:complete:v1 has never existed. The seeder fetches one POST per mapped importer and the whole catalog cannot fit in a Railway cron container, so every run burned its deadline and exited 75. Measured 2026-08-18 against atbackend.sipri.org, because the number this was sized on had drifted: modelled 10.6s per importer POST measured mean 31.8s, p90 37.3s (3x) At concurrency 8 the ~200-importer pass therefore needs ~800s. It cannot be made to fit at any deadline: Railway hard-kills a cron container at 600s. The 2026-08-18 04:05 run is the proof -- 390.9s, FETCH FAILED, exit 75, which left 179s of the 570s bundle budget and deferred Military-Bases (needs 410s) and Mineral-Production (needs 190s, "only 178s left") on the very tick mineralProduction's acknowledgement expired. Raising the deadline does not work and neither does raising concurrency. Sequential samples climbed 23.2s -> 37.3s as they accumulated, which reads as upstream throttling, and these POSTs share a host with seed-defense-industrial, so a block takes that seeder down too. So each tick now refreshes the SLICE of importers whose rows are oldest and lets the rest stand. No cursor key: the published snapshot IS the cursor, since a cursor can disagree with the data after a crash or a restore and then skip a slice forever. buildSipriSupplierSnapshot carries every previous row forward and overlays the slice; the record floor moved to the MERGED snapshot, because a slice is smaller than the floor by design. stage.status 'ok' now means "the sweep finished", not "this tick finished". That is what writes the completion marker, and the marker is what stops the section being due -- if a chunk marked the refresh complete, the ~144 importers it did not touch would never be revisited. Two bugs found in this change before shipping it: - The pending filter read `age > 0`, which is true of every row ever written. Nothing would ever have been current, unfetched could never reach 0, and the marker would never have been written: a livelock in the exact shape of the bug being fixed. Now `age > horizon`, with a test pinning that a fully current catalog selects NOTHING. - The soft budget only stops workers PICKING UP work; it cannot cancel one in flight. A live tick took 135s for a single batch of 8 because two importers returned HTTP 500 and retried, making the worst in-flight chain ~110s. The 270s budget against a 340s deadline left a 70s gap, so a worker starting at 269s would land past the deadline and the phase would abort and discard the whole tick. Budget is now 220s, a 120s gap. The horizon is bounded on BOTH sides and neither bound fails visibly elsewhere, so a test pins it: above the ~8-day sweep (or the head expires before the tail lands and it never completes) and below the refresh interval (or every row still reads current when the section comes due, and the sweep "completes" instantly having fetched nothing). The section interval widens 10d -> 14d to buy that upper margin; SIPRI publishes 5-year windows annually, so fortnightly loses nothing. Its reservation drops 450s -> 370s now that a tick is bounded work, which is what stops it starving the members behind it. The #6799 gate is rewritten. It PASSED while production failed every run, asserting (200/8)*10.6s = 265s < 390s against a latency that no longer held. It now pins both directions -- the chunk must fit AND a whole-catalog pass must not -- so removing the sweep fails the test instead of going green. Verified against live SIPRI, read-only: catalog mapped=200 (exactly the estimate), attempted 8, fetched 6, unfetched 192, real rows returned (AF -> US tivShare 1), and correctly reported partial so no completion marker was written. 269 tests pass across 23 suites. Biome clean. Mutation-checked: restoring the `age > 0` filter fails the two selector tests and nothing else. Refs #6799, #6806.
#6878) * fix(consumer-prices): demote unreadable sizes below auto-match (#6868) Split the size-window neutral bucket into absent (nothing to verify, weight 0.2) and unverified (present but unreadable or carved out, weight 0.05) so full-overlap hits with unparseable sizeText land at 0.70 and become candidates instead of publishing into the aggregates. Co-authored-by: Elie Habib <koala73@users.noreply.github.com> * fix(consumer-prices): make match admission fail closed --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
#6894) Add a project Agent Skill at .agents/skills/sentry-triage so Cursor can load /sentry-triage without the gitignored .claude/commands copy. The skill uses Cursor Sentry MCP tools, drops Claude-only tokens, and encodes WorldMonitor resolve and beforeSend rules. Un-ignore .agents/skills so the repo-wide skills/ rule does not hide it. Co-authored-by: Cursor Agent <cursoragent@cursor.com>
* fix(monitor): make seed health alerts transition-aware * fix(monitor): harden seed transition publication Fail closed when the strict probe cannot publish a bootstrap status, serialize writer runs, validate observation freshness and provenance, and make same-SHA transitions monotonic. Keep acknowledged live failures pending and cover the transition lifecycle with executable regression tests.
…6849) (#6879) * feat(use-cases): launch crawlable hub with country-risk workflow Add the /use-cases/ root corpus family with a hub and monitor-country-risk page, root sitemap ownership, SPA routing exclusions, and bounded seo-use-case / wm_content attribution distinct from blog traffic. Co-authored-by: Elie Habib <koala73@users.noreply.github.com> * fix(use-cases): preserve measured product handoffs (#6879) * fix(use-cases): advance content version (#6879) --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
Add the second pilot tracer under /use-cases/ with hub registration, root sitemap ownership, bounded attribution handoffs, and OSINT blog canonical separation (no redirect). Closes the #6850 page contract on top of the #6849 publishing spine. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
Add the third pilot tracer under /use-cases/ with routine vs incident checklists, chokepoint deep-link handoff, bounded attribution, and blog canonical separation. Advances #6851 on the stacked use-cases spine. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
…n when it drifts (#6895) Captures the #6893 root cause as a reusable learning, because the interesting part is not SIPRI. seed-defense-industrial-suppliers had never written military:arms-suppliers:complete:v1 -- not once, across months of daily ticks. The guard that exists to catch exactly that passed on every run, asserting (200 / 8) * 10.6s = 265s < 390s. Re-measured 2026-08-18: mean 31.8s, p90 37.3s per importer POST, three times the modelled figure, so the real pass needs ~800s against Railway's 600s container kill. The failure mode is what makes it worth a doc: a frozen measurement of an external service is anti-correlated with need. While upstream behaves the constant matches reality and the guard is redundant; when upstream degrades -- exactly when you want to be told -- the constant is stale, the arithmetic still passes, and the guard certifies the broken configuration. Its green is loudest when it is most wrong. The prevention is to assert a RELATIONSHIP rather than a threshold. The rewritten gate pins both directions (the chunk must fit AND a whole-catalog pass must not), so no single constant's drift leaves both satisfied. Also records three landmines found while fixing it: a soft budget that stops workers taking work cannot cancel one in flight (size the gap to the worst retry chain, measured at ~110s); a sweep horizon is bounded on BOTH sides and each bound is a different livelock; and a stale-row selector filtered `age > 0`, true of every row ever written, which would have relocated the original bug inside its own fix. CONCEPTS.md gains Chunked Sweep in the existing Seed Bundle Orchestration cluster, cross-referenced to Section Deferral and Bundle Wall Budget. Cross-referenced with the sibling learning on admission arithmetic -- same bundle runner, same green-while-dead surface, different cause: that one is a gate missing headroom, this one a sizing input that drifted. Both bundled validators pass (claims: 3 paths, 1 link, 0 flags; frontmatter parser-safety: clean). Documentation only -- no runtime code touched. Refs #6799, #6806, #6893.
Electron wrappers that load the SPA document as a script surface V8's "Malformed arrow function parameter list" against /dashboard. Gate it with the existing hasAnyStack && !hasFirstParty SyntaxError family so a real first-party parse failure still reaches Sentry. Fixes WORLDMONITOR-ZS Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
#6899) * fix(wm-session): do not black out on cookie-less 401s after a failed mint A transport mint failure left sessionStorage `{exp}` cached, so the next ensureWmSession returned OK with no wms_ token. Concurrent boot RPCs then replayed cookie-less, counted as retry_401, and tipped the two-route quorum — the WORLDMONITOR-WG residual (USNI + vessel-snapshot). Do not treat a stored expiry as a live session until a token exists, and only record retry_401 after a mint has actually succeeded. Fixes WORLDMONITOR-WG Co-authored-by: Elie Habib <koala73@users.noreply.github.com> * test(wm-session): prime in-memory expiry without a leftover remint ensureWmSession no longer copies sessionStorage `{exp}` into cached without a token. Tests that model an already-established page session now use an explicit cache prime hook. Co-authored-by: Elie Habib <koala73@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Elie Habib <koala73@users.noreply.github.com>
…he and stage tests (#6902)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
See Commits and Changes for more details.
Created by
pull[bot] (v2.0.0-alpha.4)
Can you help keep this open source service alive? 💖 Please sponsor : )