Skip to content

[pull] main from koala73:main - #2

Open
pull[bot] wants to merge 4532 commits into
kiss2u:mainfrom
koala73:main
Open

[pull] main from koala73:main#2
pull[bot] wants to merge 4532 commits into
kiss2u:mainfrom
koala73:main

Conversation

@pull

@pull pull Bot commented Mar 1, 2026

Copy link
Copy Markdown

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 : )

@pull pull Bot locked and limited conversation to collaborators Mar 1, 2026
@pull pull Bot added ⤵️ pull merge-conflict Resolve conflicts manually labels Mar 1, 2026
koala73 added 26 commits August 14, 2026 09:40
## 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.

---

[![Compound
Engineering](https://img.shields.io/badge/Built_with-Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
![GPT-5](https://img.shields.io/badge/GPT--5-000000)
## 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.

---

[![Compound
Engineering](https://img.shields.io/badge/Built_with-Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
![Claude
Code](https://img.shields.io/badge/Fable_5-D97757?logo=claude&logoColor=white)

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
koala73 and others added 30 commits August 17, 2026 18:52
)

Arms-Suppliers and Military-Bases each reserve most of leftover's
570s tick, so the other due sections starve. Land planned sibling
services and tick probes add-only; leftover still hosts both members.
…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>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

⤵️ pull merge-conflict Resolve conflicts manually

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants