fix(cross-source-signals): unwrap seed envelopes and align extractors with their writers (#5870) - #5896
Merged
koala73 merged 5 commits intoJul 31, 2026
Conversation
…t in a vm The two existing suites reconstructed the module with readFileSync + regex surgery + vm.runInContext because runSeed() ran unconditionally at import time. One of those regexes deleted readAllSourceKeys outright, which is why no test could see a defect living in that function. Add the direct-run guard every other seeder uses (seed-regulatory-actions.mjs:319) and export the extractors, so both suites import the real module. The Railway bundle spawns each seeder as its own process (_bundle-runner.mjs), so the guard is a no-op in production. Assertions are unchanged, except that the extractor-registration check now asserts membership in the exported EXTRACTORS registry instead of grepping the source text.
readAllSourceKeys did a bare JSON.parse on every pipeline result. Most of these
keys are written by contract-mode seeders, which store { _seed, data }
(_seed-utils.mjs:499), so d['<key>'] was the envelope and every extractor's
payload.<field> read undefined. The extractors returned [] without throwing, the
seeder exited 0 and published, and nothing alarmed.
unwrapEnvelope only unwraps when _seed.fetchedAt is a number, so the keys still
written in the legacy bare shape pass through byte-identical. JSON.parse stays
outside it deliberately: unwrapEnvelope accepts a raw string but returns that
string as data on a parse failure, which would register a malformed value as a
found key.
Reverting the unwrap turns 2 of the 6 new cases red.
…ters publish Unwrapping the envelope is necessary but not sufficient: several extractors also read field names and enum spellings their writer has never published, so they would still have produced nothing. Field and enum corrections, each against the writer: - thermal: status is the THERMAL_STATUS_* enum, the anomaly measure is zScore (anomalyScore exists nowhere in the repo), the label is regionLabel, and lastDetectedAt is an ISO string that safeNum() read as 0 - cyber: CRITICALITY_LEVEL_* enum, and country -- there is no targetCountry - outages: OUTAGE_SEVERITY_TOTAL/MAJOR, and no affectedUsers field; there is also no CRITICAL tier - radiation: the observation carries a RADIATION_SEVERITY_* enum and observedAt, not alert/status/threshold/timestamp - weather: NWS severity is title-cased, and the area field is areaDesc - advisories: the level slug is hyphenated, 'do-not-travel' - unrest: events carry occurredAt, so the 24h cutoff compared against 0, and the "|| !e.date" escape hatch let the whole feed through as recent - earthquakes: occurredAt, so every quake was stamped with the run clock - gpsjam: fetchedAt is an ISO string - military flights: the record has operatorCountry and no region/country/origin, so every flight collapsed into one 'Global' bucket and the signal could never join a theater composite - forecasts: computeTrends only assigns stable/rising/falling and there is no direction field, so both of those clauses were dead - shipping: the key publishes rate indices, not routes; keyed off the writer's own spikeAlert flag rather than a threshold invented here Deliberately not "fixed": - displacement: the key publishes an annual UNHCR stock with no flow or trend field, so a surge is not derivable from it. Left silent and documented rather than firing off an invented totalDisplaced cutoff. - media tone: the bundled-canonical fallback read topic.avgTone/tone, neither of which that key publishes. Removed, and intelligence:gdelt-intel:v1 dropped from SOURCE_KEYS with it. - risk scores: region is an ISO2 code that does not map to a theater; noted in place, since resolving it needs a country resolver this seeder does not have. Also registers military:flights:stale:v1, which extractMilitaryFlightSurge already falls back to but which was never fetched. The live key has a 600s TTL against this seeder's 15min cadence, so the stale key is the one that carries the data on most runs (seed-correlation.mjs:14 registers the same pair). The new suite drives every extractor through the exact transform readAllSourceKeys performs, and pins theater and payload-sourced timestamps rather than just asserting non-empty output. Reverting any one of the 23 guards turns it red: 23 mutants, no survivors.
…oala73#5870 Acceptance criterion 2: extractWildfireEscalation must produce a signal from a realistic wildfire:fires:v1 payload. Three defects stacked on that one key -- the envelope, fires vs fireDetections, and radiativePower/severity vs frp with no severity field at all. Only the brightness clause was ever correct, which is why the extractor read plausibly and produced nothing. The fixture is field-for-field the record seed-fire-detections.mjs builds from a FIRMS VIIRS row, and one case asserts the envelope shape still yields nothing -- that is what production saw.
|
@Yigtwxx is attempting to deploy a commit to the World Monitor Team on Vercel. A member of the Team first needs to authorize it. |
25 tasks
Yigtwxx
added a commit
to Yigtwxx/worldmonitor
that referenced
this pull request
Aug 8, 2026
…nput reads
fetchInputData bare-JSON.parse'd all nine INPUT_KEYS. Seven of them are
written by contract-mode seeders as { _seed, data }, so computeCorrelation's
field reads saw the envelope, resolved to undefined and fell through to [].
Only the two military:flights keys survived, because seed-military-flights.mjs
still writes bare — which left escalation, economic and disaster computing
over empty inputs.
It was silent: the hasAnyData tripwire tests `data[k] != null`, and an
envelope object is not null, so it never fired. The publish then failed its
card floor and runSeed resolved RETRY, holding the previous cards alive
without advancing _seed.fetchedAt.
Same defect, same fix and same reusable helper as koala73#5870 / koala73#5896 one seeder
over. Adds the per-key freshness gate that fix established, so unwrapping
cannot revive a preserved last-good envelope into cards stamped with a fresh
computedAt; every budget is the source seeder's own declared maxStaleMin.
_seed-envelope-source.mjs is already in the derived-signals bundle's
watchPatterns via _seed-utils.mjs, so no deploy manifest change is needed.
koala73
added a commit
that referenced
this pull request
Aug 10, 2026
* fix(correlation): unwrap seed envelopes in the correlation seeder's input reads
fetchInputData bare-JSON.parse'd all nine INPUT_KEYS. Seven of them are
written by contract-mode seeders as { _seed, data }, so computeCorrelation's
field reads saw the envelope, resolved to undefined and fell through to [].
Only the two military:flights keys survived, because seed-military-flights.mjs
still writes bare — which left escalation, economic and disaster computing
over empty inputs.
It was silent: the hasAnyData tripwire tests `data[k] != null`, and an
envelope object is not null, so it never fired. The publish then failed its
card floor and runSeed resolved RETRY, holding the previous cards alive
without advancing _seed.fetchedAt.
Same defect, same fix and same reusable helper as #5870 / #5896 one seeder
over. Adds the per-key freshness gate that fix established, so unwrapping
cannot revive a preserved last-good envelope into cards stamped with a fresh
computedAt; every budget is the source seeder's own declared maxStaleMin.
_seed-envelope-source.mjs is already in the derived-signals bundle's
watchPatterns via _seed-utils.mjs, so no deploy manifest change is needed.
* fix(correlation): harden seeder input reads
---------
Co-authored-by: Yigtwxx <yigiterdogan023@gmail.com>
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 join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Summary
Fixes #5870.
readAllSourceKeys()bare-parsed every value it read:Most of those keys are written by contract-mode seeders, which store
{ _seed, data }(scripts/_seed-utils.mjs:499). Sod['<key>']was the envelope,payload.clusters/payload.quotes/payload.threatswereundefined, theArray.isArray(...)guard on the next line wasfalse, and the extractor returned[]. No throw, so thetry/catchin the aggregator never logged. The seeder still exited 0 and published a shorter array.Auditing all of it turned up a second, independent layer: even after a correct unwrap, most extractors read field names and enum spellings their writer has never published.
extractWildfireEscalationis the clearest case — it filters onf.radiativePower > 5000 || f.severity === 'extreme'against a payload whose detections carryfrpand have noseverityfield at all, with FRP reported in MW (the writer's own significance threshold is 80, not 5000). Only itsbrightness > 400clause was ever correct, which is why the code reads plausibly and produces nothing.So this is three fixes on one seeder: unwrap the envelope, align every extractor with its writer, and make the suite able to see both.
The audit
The issue asks for all source keys to be checked against their writers, so here is the result. "Enveloped" = the writer passes
declareRecordstorunSeed, which is what turns on contract mode (_seed-utils.mjs:1758).extractThermalSpikethermal:escalation:v1THERMAL_STATUS_*enum,zScore,regionLabel, ISOlastDetectedAtextractGpsJammingintelligence:gpsjam:v2fetchedAtis an ISO string, sosafeNum()read 0extractMilitaryFlightSurgemilitary:flights:{v1,stale:v1}operatorCountry, notregion/country/originextractUnrestSurgeunrest:events:v1occurredAt;locationis a coordinate objectextractOrefAlertClusterintelligence:advisories-bootstrap:v1do-not-travel, hyphenatedextractVixSpikemarket:stocks-bootstrap:v1extractCommodityShockmarket:commodities-bootstrap:v1extractCyberEscalationcyber:threats-bootstrap:v2CRITICALITY_LEVEL_*, andcountry(notargetCountry)extractShippingDisruptionsupply_chain:shipping:v2extractSanctionsSurgesanctions:pressure:v1extractEarthquakeSignificantseismology:earthquakes:v1occurredAtextractRadiationAnomalyradiation:observations:v1RADIATION_SEVERITY_*,observedAtextractInfrastructureOutageinfra:outages:v1OUTAGE_SEVERITY_TOTAL/MAJOR; noaffectedUsers, noCRITICALtierextractWildfireEscalationwildfire:fires:v1fireDetections,frp,possibleExplosionextractDisplacementSurgedisplacement:summary:v1:<year>extractForecastDeteriorationforecast:predictions:v2extractMarketStressmarket:stocks-bootstrap:v1extractWeatherExtremeweather:alerts:v1areaDesc, nocategoryextractMediaToneDeteriorationgdelt:intel:tone:*extractRiskScoreSpikerisk:scores:sebuf:stale:v8regionlimitation documented in placeextractRegulatoryActionregulatory:actions:v1military:flights:stale:v1is now registered inSOURCE_KEYS.extractMilitaryFlightSurgealready falls back to it, but it was never fetched, sod['military:flights:stale:v1']was alwaysundefined. The writer publishes the same payload to both keys (seed-military-flights.mjs:1327) withLIVE_TTL = 600against this seeder's 15-minute cadence, so the live key is absent on most runs and the stale key is the one that carries the data.seed-correlation.mjs:14registers the same pair.intelligence:gdelt-intel:v1is dropped fromSOURCE_KEYS: its only reader was the media-tone fallback, which readtopic.avgTone || topic.toneagainst topic objects shaped{ id, articles, fetchedAt, attemptedAt, _tone, _vol }(seed-gdelt-intel.mjs:438).safeNum()returned 0,0 > -3skipped every topic, so the branch could not fire even after the unwrap. Rebuilding it ontopic._tonewould mean re-deriving the trend and staleness rules the per-topic path already implements under #5478/#5863 review, so it is removed rather than half-restored.Why
JSON.parsestays outsideunwrapEnvelopeunwrapEnvelopeaccepts a raw string, sounwrapEnvelope(raw).datawould have been shorter. It also returns that string asdatawhen the parse fails, which would register a malformed value as a found key and inflate theFound N/M source keys populatedline. Parsing first preserves the existing skip-malformed behaviour exactly, andunwrapEnvelopeshort-circuits its own string branch when handed an object, so nothing is parsed twice.Legacy safety is structural rather than by-key:
unwrapEnvelopeonly unwraps when_seed.fetchedAtis a number (_seed-envelope-source.mjs:59). The one live payload that looks like a half-envelope isgdelt:intel:tone:*— a top-leveldataarray plus afetchedAt, no_seed— and there is a dedicated test pinning that it passes through untouched, since unwrapping it would kill the one tone path that works today.Design decisions for maintainer review
These are calibration questions, not read fixes. I took the conservative option in each case and left the alternative here rather than deciding it in code.
displacement:summary:v1:<year>publishes an annual UNHCR stock —summary.countries[]withrefugees/idps/totalDisplaced(seed-displacement-summary.mjs:175) — and carries no flow, delta or trend field anywhere. The extractor readcrises[].newDisplacementsand.trend, which the key has never published. This is not a rename away from working: a surge is a flow, and picking atotalDisplacedcutoff to stand in for one would invent a calibration rather than repair a read. It returns[]with the reason in place. The alternative is to redefine the signal as stock-level and rename it; happy to do that here or in a follow-up if you prefer.frp > 5000, this usespossibleExplosion— the significance flag the writer itself publishes (frp > 80 && brightness > 380). The per-theatercount < 5floor and the/50score divisor are untouched, but both were calibrated against a shape that never produced anything, so they have never actually been exercised.spikeAlertrather than a newchangePctthreshold.SPIKEonly, notELEVATED.BASE_WEIGHTis 3.5, so a singleELEVATEDreading would scoreCRITICALon its own.North America. NWS alerts carryareaDesc("Kern County, CA; Tulare County, CA") and no country or region. Bucketing on that string gives almost every alert its own theater, which can never join a composite; the feed is US-only, so the theater is fixed by construction.operatorCountry. That is whose air force is flying, not where — the closest proxy this payload exposes. The writer also publishes geo-bucketed theaters totheater-posture:sebuf:v1, which would be more accurate and a larger change; say the word and I will use it instead.computeTrends(seed-forecasts.mjs:2720) only assignsstable/rising/falling, sotrend === 'deteriorating'anddirection === 'negative'were dead. Whetherrisingshould also qualify is your call.Enum comparisons use dual-accept (
OUTAGE_SEVERITY_MAJORandmajorboth match), followingseed-correlation.mjs:212. That makes every change strictly additive — no signal that fires today can stop firing.Why the tests could not see this
Both existing suites reconstructed the module with
readFileSync+ regex +vm.runInContext, becauserunSeed(...)ran unconditionally at import time. One of those regexes deletedreadAllSourceKeysoutright:and the fixtures were hand-built bare payloads, i.e. the pre-envelope world, at exactly the seam where the defect lives.
So this adds the direct-run guard 118 of the 163
scripts/seed-*.mjsfiles already use (seed-regulatory-actions.mjs:319is the template) and exports the extractors, and both suites now import the real module. The guard is a no-op in production:scripts/_bundle-runner.mjsspawns each section as its own process with the script path asargv[1], andseed-china-decision-signals.mjs:155in this same bundle is both guarded and imported byseed-bundle-derived-signals.mjs:3. Existing assertions are unchanged, except that the extractor-registration check now asserts membership in the exportedEXTRACTORSregistry instead of grepping the source text.Verification
Targeted suites, all on this branch:
Every guard is mutation-proven. Reverting any one of the 23 corrections to the code it replaced turns the suite red — 23 mutants, no survivors:
fetchedAtoperatorCountryoccurredAtindicesoccurredAtThree of those only started failing after the fixture matrix asserted the expected
theaterand payload-sourceddetectedAtrather than just non-empty output —military,earthquakeandgpsjamall survived a "does it fire" assertion, because a signal that silently falls back totheater: 'Global'anddetectedAt: Date.now()still fires. That is precisely how the military-flight signal sat outside every theater composite, so the assertions are pinned exactly.Every fixture is field-for-field the record its writer builds, with a
file:lineprovenance comment, and each one is driven through the exact transformreadAllSourceKeysperforms rather than hand-unwrapped. Enveloped fixtures additionally assert that the raw envelope produces nothing, which pins the bug itself.Other gates:
npm run test:data: identical failure set toorigin/main— 45 failing test names on both,commdiff empty in both directions (OpenAPI contract, docs/i18n, pricing and Docker suites that are already red on a clean checkout).The seeder was not run against production Redis.
Out of scope
scripts/seed-correlation.mjs:40reads its ownINPUT_KEYSwith the same bareJSON.parseand sits next to this file in the same Railway bundle. Its inputs are mostly legacy bare keys so the blast radius is smaller, but it is the same defect and worth its own issue.MAX_SIGNALS = 30cap and the per-extractor.slice(0, 2..5)limits are untouched. More signals will now reachlist-cross-source-signalsand the composite detector — that is the point of the fix — but both consumers already take a bounded array, and changing the caps in the same PR would mix a bug fix with a tuning change._country-brief-context/ prompt-context work in fix(intel): newline forges prompt rows in three sibling prompt-context modules (out of #5857's scope) #5881 is unrelated and not touched here.Type of change
Affected areas
/api/*)scripts/seed-cross-source-signals.mjs(Railway seeder feedingintelligence:cross-source-signals:v1)Checklist
intelligence:cross-source-signals:v1. Verified through the extractor suites instead, against fixtures derived field-for-field from each writer.api/rss-proxy.jsallowlist (if adding feeds) — N/A, no feeds added.npm run typecheck)Documentation Alignment Checklist
N/A — this PR does not publish or change any documentation claim. It changes how one seeder reads existing Redis keys and does not alter the published shape of
intelligence:cross-source-signals:v1, the OpenAPI contract forlist-cross-source-signals, methodology, or any generated doc. Listed for completeness:SOURCE_KEYSentry is mapped to its writer and its envelope mode.