fix(news-ingest): drop static institutional pages before they reach the brief (PR 1/4)#3417
Conversation
…he brief
Three Pentagon static pages (About Section 508, Acquisition Transformation
Strategy, 5G Ecosystem report) shipped to a paying user's brief alongside
real news. Static landing pages have no publication date and no news-ness;
they should never enter the brief at all.
Five compounding ingest-side bugs let them through. PR-1 closes the first
three (PRs 2-4 follow per docs/plans/2026-04-26-001-fix-brief-static-page-
contamination-plan.md):
U1 — every gn(...) feed now carries a when:Nd time gate. 11 ungated feeds
fixed: AP News, Reuters World/US/Business (×2), White House, State Dept,
Pentagon, Treasury, DOJ, Layoffs.fyi, Bellingcat, Arms Control Assn,
Bulletin of Atomic Scientists. Without when:Nd, Google News surfaces any
indexed page on the target domain — including static institutional pages
with no temporal relevance. Regression-locked via tests/feeds-time-gate.
U2 — RSS items missing a real pubDate are dropped at parse time instead
of being silently stamped with Date.now(). Two-step gate:
(a) Expanded date-tag priority list to recognize Dublin Core dialects
(<dc:date>, <dc:Date.Issued>) alongside <pubDate>/<published>/
<updated>, so ArXiv-class feeds don't silently zero out.
(b) After exhausting every recognized tag, drop items with empty/
unparseable dates, or dates >1h in the future (clock skew).
Per-feed feedStatus = 'all-undated' (silent-zeroing) | 'partial-undated'
(informational) classification + structured FEED_HEALTH_WARNING console
log so log aggregation can keyword-match. parseRssXml return shape changed
from items[] | null to { items, parsedTotal, droppedUndated } | null;
internal cache prefix bumped rss:feed:v1: → rss:feed:v2: to avoid shape
collision during deploy.
U3 — hard freshness floor (default 48h, NEWS_MAX_AGE_HOURS env override)
applied BEFORE corroboration counting so a stale duplicate of a fresh
story can't inflate the cluster's source count. Belt-and-suspenders
against any feed that surfaces valid-but-stale pubDate.
importance-score-parity.test.mjs continues to pass — formula untouched.
Deferred (separate PRs per the plan): U4 LLM classify-cache tier cap +
3-site v3→v4 prefix bump (incl. ais-relay.cjs's independent cache); U5
brief-side source-topic cap; U6 ops audit + URL classifier helper; U7
brief-filter URL/path denylist. The api/health.js Redis-key wiring for
'all-undated' is also deferred — it requires a new news:feed-health:v1
key, writer, and classifier; tracked as follow-up to U2.
Plan: docs/plans/2026-04-26-001-fix-brief-static-page-contamination-plan.md
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR closes three compounding ingest-side bugs (U1–U3) that caused static Pentagon institutional pages to appear in user briefs: it time-gates all 11 ungated
Confidence Score: 3/5Safe to merge for user-facing contamination prevention, but the 'all-undated' feedStatus classification is broken dead code that misleads any future health-check wiring. A single P1 bug: the 'all-undated' feedStatus branch introduced in this PR is unreachable, meaning all-undated feeds will appear as 'empty' in feedStatuses. The PR description explicitly promises this classification as a monitoring signal. Log aggregation via console.warn still works, but the structural classification is wrong and will silently mislead any future api/health.js wiring that reads feedStatuses. server/worldmonitor/news/v1/list-feed-digest.ts — specifically the parseRssXml null-return contract vs. the buildDigest all-undated classification guard Important Files Changed
Sequence DiagramsequenceDiagram
participant BD as buildDigest
participant FAP as fetchAndParseRss
participant CFJ as cachedFetchJson
participant PRX as parseRssXml
BD->>FAP: fetchAndParseRss(feed, variant, signal)
FAP->>CFJ: cachedFetchJson(cacheKey, 3600, factory)
CFJ->>PRX: parseRssXml(text, feed, variant)
alt All items have valid dates
PRX-->>CFJ: ParseResult {items, parsedTotal>0, droppedUndated}
CFJ-->>FAP: ParseResult (cached)
FAP-->>BD: ParseResult
BD->>BD: feedStatuses = partial-undated or unset
else Some items dropped (partial-undated)
PRX->>PRX: console.warn partial-undated
PRX-->>CFJ: ParseResult {items, parsedTotal>0, droppedUndated>0}
CFJ-->>FAP: ParseResult (cached)
FAP-->>BD: ParseResult
BD->>BD: feedStatuses[feed.name] = partial-undated
else ALL items dropped (all-undated) BUG
PRX->>PRX: console.warn FEED_HEALTH_WARNING
PRX-->>CFJ: null (items.length === 0)
CFJ-->>FAP: null (cached)
FAP-->>BD: fallback {items:[], parsedTotal:0, droppedUndated:0}
BD->>BD: result.parsedTotal > 0 is FALSE
BD->>BD: feedStatuses[feed.name] = empty (should be all-undated)
end
|
| if (result.parsedTotal > 0 && result.items.length === 0 && result.droppedUndated > 0) { | ||
| feedStatuses[feed.name] = 'all-undated'; |
There was a problem hiding this comment.
'all-undated' feedStatus is unreachable — branch is dead code
parseRssXml returns null whenever items.length === 0 (line 339), including the all-undated case. fetchAndParseRss then falls back to { items: [], parsedTotal: 0, droppedUndated: 0 } (line 219), so by the time this classification runs, result.parsedTotal is always 0, making the result.parsedTotal > 0 guard permanently false. Every all-undated feed is misclassified as 'empty' in feedStatuses instead.
The FEED_HEALTH_WARNING console.warn inside parseRssXml still fires before the null return, so log-aggregation monitoring continues to work — but any code path that reads feedStatuses to detect the 'all-undated' condition (e.g., a future api/health.js wiring) will never see it.
Fix option A — return stats even when all items dropped (break the null-means-empty cache contract, requires updating the fallback):
// In parseRssXml, always return a ParseResult:
return { items, parsedTotal, droppedUndated };
// (callers already handle items.length === 0)Fix option B — propagate stats through fetchAndParseRss separately, preserving the null contract for the cache but exposing stats out-of-band (e.g., a let lastStats closure).
… on #3417) parseRssXml previously returned null when items.length === 0, conflating two distinct cases: (a) Genuinely empty feed (parsedTotal=0): channel exists, no items. (b) All-undated feed (parsedTotal>0, droppedUndated>0): every item dropped for missing/unparseable date — the silent-zeroing failure mode. Both flowed through cachedFetchJson and were converted by fetchAndParseRss's `cached ?? { items: [], parsedTotal: 0, droppedUndated: 0 }` fallback into all-zero stats. The buildDigest classification at the caller could only distinguish 'empty' from the populated case — 'all-undated' was unreachable. Net effect: the structured feedStatus signal that downstream consumers need to detect a date-dialect regression was broken. The cold-parse FEED_HEALTH_ WARNING console log still fired (so it wasn't fully invisible), but the programmatic surface — the whole point of distinguishing the two — was off. Fix: parseRssXml now ALWAYS returns the struct. null is reserved for the upstream fetch/no-XML failure path inside fetchAndParseRss's callback. Tightens the return type from `ParseResult | null` to `ParseResult`. Tests now exercise both: - all-undated case asserts struct returned with parsedTotal=N, items=[], droppedUndated=N (was: assert.equal(result, null)) - genuinely-empty-feed case asserts struct returned with all-zero stats (new test covering the boundary) 77/77 tests pass across the affected surface; importance-score-parity preserved.
…eeds (review fix on #3417) The previous review fix (3dd6ced) made parseRssXml always return the struct so 'all-undated' stats survived the cache layer. That correctly fixed the unreachable-classification bug, but it ALSO changed genuinely empty / malformed / block-page responses from returning null (which cachedFetchJson writes as NEG_SENTINEL with 120s TTL) to returning a positive-cached `{ items: [], parsedTotal: 0, ... }` struct that pinned the entry for the full 3600s feed TTL. Net effect: a transient Cloudflare interstitial, upstream maintenance window, or malformed RSS response would suppress that feed for an hour instead of retrying after 2 minutes. Real resilience downgrade. Right cut line: parsedTotal. When parsedTotal > 0 we recognized at least one <item>/<entry> block in the XML, so the stats are meaningful — return the struct (positive cache, 'all-undated' detection works). When parsedTotal === 0 we found no recognizable items at all (empty channel, malformed XML, block page that doesn't match item/entry regexes) — return null so cachedFetchJson writes NEG_SENTINEL for the short negativeTtl, and the next poll retries instead of inheriting an hour-long stale. Tests: - all-undated case still asserts struct returned (unchanged from 3dd6ced) - genuinely-empty-feed case flipped: now asserts null returned - new test covers the Cloudflare-style block-page response: HTML body with no <item>/<entry> → null → short-TTL retry 71/71 tests pass; importance-score-parity preserved.
…+U7) Closes U5, U6, U7 of docs/plans/2026-04-26-001-fix-brief-static-page- contamination-plan.md. Three brief-side guards that complement the ingest-side hardening from PR #3417 + the classify-cache fix in the prior commit. U5 — source-topic cap (default 2 per source+category) filterTopStories now drops the 3rd+ story sharing a (source, category) pair within a single brief. Surgical fix for the editorial-clutter case where 2026-04-25-2001 shipped both "Millions under tornado threat" and "Watch tornadoes swirl through Oklahoma" from CBS News — distinct stories the dedup correctly kept separate, but redundant. Cap is applied AFTER applyRankedOrder, so the highest-importance member of each pair survives. Tunable via maxPerSourceTopic param (default 2, pass Infinity to disable). Emits onDrop with reason 'source_topic_cap'. U6 — shared URL classifier + ops audit script Pure helper at shared/url-classifier.js (mirrored to scripts/shared/ byte-identical, enforced by tests/edge-functions.test.mjs per the shared-dir-mirror-requirement learning). isInstitutionalStaticPage(url) is conservative-by-design: must match BOTH .gov/.mil/.int host AND a curated path prefix (/About/, /Section-, /Strategy/, /Strategies/, /Policy/, /Policies/, /Resources/, /Programs/, /Acquisition- Transformation-Strategy). Plus the ops script scripts/audit-static-page-contamination.mjs that SCANs story:track:v1:* with cursor-based batching, classifies each via the helper, and on --apply DELs both the track key and its story:sources:v1:{hash} sibling. Dry run reports per-host + per-path-pattern stats so any patterns the starter regex misses surface in production data and can drive a follow-up tightening PR. U7 — brief-filter URL denylist (defense in depth) After normaliseSourceUrl returns a valid URL, filterTopStories now calls isInstitutionalStaticPage(sourceUrl) and drops with reason 'institutional_static_page' when true. Last line of defense — the ingest gates from PR #3417 should prevent these reaching story:track: v1 in the first place, but a regression in the feed registry or a new date-dialect bypassing U2 could let one through, and the brief surface is the user-visible failure mode. Test additions: - tests/url-classifier.test.mjs (24 cases) — known contamination URLs classify true; legitimate /News/ paths on the same hosts classify false; non-institutional hosts classify false; defensive on bad input (no throws). - tests/brief-filter.test.mjs +9 cases for U5/U7 — within cap, over cap, source/category isolation, ranked-order survival, Multiple-wires/General fallbacks, U7 institutional-static-page drop with onDrop reason, maxPerSourceTopic override. - tests/brief-from-digest-stories.test.mjs — 3 fixtures updated to vary sources so the source-topic cap doesn't dominate tests of other behaviors (maxStories cap, ranking). - tests/edge-functions.test.mjs +1 line — adds url-classifier.js to the explicit-mirrored-files set so future drift fails CI. DropMetricsFn typedef extended with 'source_topic_cap' and 'institutional_static_page' reasons. The reconciliation invariant test (in === out + sum(dropped_*)) updated to include both new reasons. 533/533 tests across the touched + parity-coupled surface (parity test preserved — formula untouched). Plan: docs/plans/2026-04-26-001-fix-brief-static-page-contamination-plan.md
…rds (PR 2/2) (#3419) * fix(classify-cache): cap LLM upgrades at +2 tiers + bump prefix v3→v4 (3 sites atomic) Closes U4 of docs/plans/2026-04-26-001-fix-brief-static-page-contamination- plan.md. The third compounding bug behind the Pentagon static-page brief contamination: the classify:sebuf:v3:* LLM cache could promote any keyword classification to any LLM-classified level, regardless of the gap between them. A title like "About Section 508" with keyword=info (no-match fallback at confidence 0.3) could end up with cache hit level=high, which combined with Pentagon's tier-1 source-tier boost cleared every importance-score threshold downstream. Two-part fix: 1. Tier cap. capLlmUpgrade(keywordLevel, llmLevel) clamps to keywordRank + 2. Blocks the contamination class: - keyword=info + LLM=critical → caps at medium (info+2) - keyword=info + LLM=high → caps at medium Preserves legitimate upgrades: - keyword=medium + LLM=critical → critical (medium+2 = critical) - keyword=low + LLM=high → high (low+2 = high) Tradeoff: keyword=low + LLM=critical caps at high (low+2 = high). Logged on every cap-fire so the loss is measurable, not silent. Reviewer noted +1 was too aggressive; +2 is the right cut line. 2. Prefix bump v3→v4 across all three sites atomically. - server/worldmonitor/intelligence/v1/_shared.ts (canonical writer) - server/worldmonitor/news/v1/list-feed-digest.ts — refactored to import buildClassifyCacheKey from _shared so the literal lives in exactly ONE place. Future bumps only need to touch _shared.ts + the relay's independent inline helper. - scripts/ais-relay.cjs:3138 — the relay maintains its own helper because .cjs cannot import from .ts. Updated in lockstep. Static-analysis test (tests/news-classify-cache-prefix-audit.test.mjs) grep-asserts zero non-canonical `classify:sebuf:vN` literals across .ts/.mjs/.cjs/.js/.json — defense against the next bump leaving the relay site behind. The cache-prefix-bump-propagation-scope learning is the recipe. Tier cap tests at tests/news-classify-cache-tier-cap.test.mts cover within-cap, contamination case, malformed LLM levels, and the keyword=critical edge case (the existing 0.9-confidence guard at list-feed-digest.ts:480 already skips cache for keyword=critical; the cap is layered defense). 16 new tests pass, 75 tests total across the touched + parity-coupled surface (importance-score-parity preserved — formula untouched). Plan: docs/plans/2026-04-26-001-fix-brief-static-page-contamination-plan.md * fix(brief-filter): source-topic cap + URL denylist + ops audit (U5+U6+U7) Closes U5, U6, U7 of docs/plans/2026-04-26-001-fix-brief-static-page- contamination-plan.md. Three brief-side guards that complement the ingest-side hardening from PR #3417 + the classify-cache fix in the prior commit. U5 — source-topic cap (default 2 per source+category) filterTopStories now drops the 3rd+ story sharing a (source, category) pair within a single brief. Surgical fix for the editorial-clutter case where 2026-04-25-2001 shipped both "Millions under tornado threat" and "Watch tornadoes swirl through Oklahoma" from CBS News — distinct stories the dedup correctly kept separate, but redundant. Cap is applied AFTER applyRankedOrder, so the highest-importance member of each pair survives. Tunable via maxPerSourceTopic param (default 2, pass Infinity to disable). Emits onDrop with reason 'source_topic_cap'. U6 — shared URL classifier + ops audit script Pure helper at shared/url-classifier.js (mirrored to scripts/shared/ byte-identical, enforced by tests/edge-functions.test.mjs per the shared-dir-mirror-requirement learning). isInstitutionalStaticPage(url) is conservative-by-design: must match BOTH .gov/.mil/.int host AND a curated path prefix (/About/, /Section-, /Strategy/, /Strategies/, /Policy/, /Policies/, /Resources/, /Programs/, /Acquisition- Transformation-Strategy). Plus the ops script scripts/audit-static-page-contamination.mjs that SCANs story:track:v1:* with cursor-based batching, classifies each via the helper, and on --apply DELs both the track key and its story:sources:v1:{hash} sibling. Dry run reports per-host + per-path-pattern stats so any patterns the starter regex misses surface in production data and can drive a follow-up tightening PR. U7 — brief-filter URL denylist (defense in depth) After normaliseSourceUrl returns a valid URL, filterTopStories now calls isInstitutionalStaticPage(sourceUrl) and drops with reason 'institutional_static_page' when true. Last line of defense — the ingest gates from PR #3417 should prevent these reaching story:track: v1 in the first place, but a regression in the feed registry or a new date-dialect bypassing U2 could let one through, and the brief surface is the user-visible failure mode. Test additions: - tests/url-classifier.test.mjs (24 cases) — known contamination URLs classify true; legitimate /News/ paths on the same hosts classify false; non-institutional hosts classify false; defensive on bad input (no throws). - tests/brief-filter.test.mjs +9 cases for U5/U7 — within cap, over cap, source/category isolation, ranked-order survival, Multiple-wires/General fallbacks, U7 institutional-static-page drop with onDrop reason, maxPerSourceTopic override. - tests/brief-from-digest-stories.test.mjs — 3 fixtures updated to vary sources so the source-topic cap doesn't dominate tests of other behaviors (maxStories cap, ranking). - tests/edge-functions.test.mjs +1 line — adds url-classifier.js to the explicit-mirrored-files set so future drift fails CI. DropMetricsFn typedef extended with 'source_topic_cap' and 'institutional_static_page' reasons. The reconciliation invariant test (in === out + sum(dropped_*)) updated to include both new reasons. 533/533 tests across the touched + parity-coupled surface (parity test preserved — formula untouched). Plan: docs/plans/2026-04-26-001-fix-brief-static-page-contamination-plan.md * fix(brief-filter+audit+types): wire new drop reasons + fix HGETALL shape (review fixes on #3419) Three review findings: 1. scripts/seed-digest-notifications.mjs drop log was missing the new reasons. filterTopStories now emits `source_topic_cap` (U5) and `institutional_static_page` (U7), but the seeder's per-attempt drop line only tabulated severity/headline/url/shape/cap. Operator reconciliation (in === out + sum(dropped_*)) silently broke when either new guard fired. Both reasons added to the dropStats init block AND the structured log format. brief-quality-report.mjs's regex parser is generic so legacy parsing keeps working; adding per-reason rates to the report summary is a small follow-up. 2. scripts/audit-static-page-contamination.mjs HGETALL normalization had a dead branch. `if (!Array.isArray(arr)) return null` ran BEFORE the object-shape branch, so any Upstash response shaped as `{k1:v1, k2:v2}` (newer client versions / direct REST) would normalize to null and the audit would silently miss those rows AND --apply would delete nothing for them. Reordered: object branch runs first (with empty-object check), then array branch for the flat ['k1','v1','k2','v2',...] shape. 3. shared/brief-filter.d.ts was stale. DropMetricsFn was missing `source_topic_cap` and `institutional_static_page` in its reason union, and filterTopStories was missing the `maxPerSourceTopic` parameter. TypeScript consumers couldn't use the new option or exhaustively switch on the new reasons. Both updated with doc comments matching the JS implementation. 311/311 tests pass; importance-score-parity preserved. * perf(brief-filter): replace O(n²) source-topic pair count with O(1) Map lookup Greptile P2 on PR #3419: the U5 source-topic cap iterates over the entire in-flight `out` array per candidate to count survivors with the same (source, category) pair. With the default maxStories=12 this is at most ~144 comparisons — harmless today, but the Map-based version is both faster and more robust to future cap-relaxation experiments. Fix: introduce a `pairCounts: Map<string, number>` initialised before the story loop. The cap check is now O(1) (`pairCounts.get(key) ?? 0`), and the counter is incremented atomically after each successful out.push(). Key construction uses `String.fromCharCode(31)` (ASCII Unit Separator 0x1F) as the delimiter rather than a literal space, preventing a subtle collision class: under a space delimiter, ('Reuters', 'World Politics') and ('Reuters World', 'Politics') both produce the same Map key 'Reuters World Politics' and would share a counter — over- or under-dropping each other depending on encounter order. With 0x1F (which never appears in source/category text), the keys stay distinct. Added a regression test for the collision case so any future delimiter change must demonstrate it preserves the property. 312/312 tests pass; importance-score-parity preserved. * fix(url-classifier+comment): segment-boundary prefix match + correct cap rationale (review fixes on #3419) Two findings from review: P2 — STATIC_PATH_PREFIXES with trailing slashes used a naive startsWith check, so `/About/` matched `/about/section-508` but NOT the bare `/about` form (the canonical landing-page URL on most .gov sites). Same for `/Strategy/`, `/Strategies/`, `/Policy/`, `/Policies/`, `/Resources/`, `/Programs/`. That weakened both U7 brief-side guard and U6 audit cleanup for very common contamination shapes. Fix: pathMatchesPrefix(path, prefix) helper with two rules: - Prefix ends with '/' → segment-bucket. Drop trailing slash, match exact stem OR `stem/...`. So `/About/` matches `/about` AND `/about/section-508`, but NOT `/aboutface` (segment boundary enforced — no over-match on prefix-letter collisions). - Prefix doesn't end with '/' → wildcard literal startsWith. So `/Section-` keeps matching `/section-508`, `/section-504`, etc., where the dash-suffix is part of the bucket name. Wildcard prefixes (`/Section-`, `/Acquisition-Transformation-Strategy`) are unchanged. Segment-bucket prefixes (the 7 `/.../` entries) now match both with-trailing-slash and bare forms. 5 new tests cover bare-segment hits (`/About`, `/Strategy`, `/Policy`) + 2 over-match guards (`/aboutface`, `/strategist`). P3 — LEVEL_RANK doc comment in list-feed-digest.ts:73-76 said "low → critical preserved because low+2=critical". The arithmetic is wrong (low=1, +2=3=high, not critical) and contradicted both the test assertions (low → critical caps to high) and the actual implementation. Comment rewritten to enumerate cap behavior per keyword level and correctly describe the bounded loss (low → critical capped at high, logged on every cap-fire). Behavior unchanged. 284/284 tests pass; importance-score-parity preserved. * docs(classify-cache): fix two more stale low+2=critical claims (review fix on #3419) Reviewer caught a third stale comment site missed in the prior P3 fix (commit f40b403). The top-level LEVEL_RANK rationale was correct; the inline comment inside enrichWithAiCache (line 525) and the header of tests/news-classify-cache-tier-cap.test.mts both still asserted "low+2 = critical, low → critical preserved." The arithmetic is wrong (low=1, +2=3=high) and contradicts the test that asserts the opposite. Rewritten to match LEVEL_RANK doc + actual implementation. Plan doc test-scenarios block (line 269) also corrected for the same reason — that one is local-only since docs/plans/ is gitignored, but keeping it consistent for future-me. Behavior unchanged. 106/106 tests pass.
…e mode (#3422) * fix(brief-ingest): READ-time freshness + direct-RSS migration + U6 age mode Three coupled gap closures discovered after PR #3419 merged. Brief 2026-04-26-0802 still surfaced two months-old Pentagon items DESPITE PR #3417's `when:1d` ingest gate working perfectly (verified live: the gated query returns zero Pentagon items in the last 24h). The cause was post-deploy residue, not the gate. See: skill: ingest-gate-tightening-leaves-residue-in-read-path Fixes (all on the same PR because they share the publishedAt persistence precondition): 1. READ-time freshness floor in buildDigest (seed-digest-notifications.mjs). Drops story:track:v1 rows whose source publishedAt is older than DIGEST_READ_MAX_AGE_HOURS (default 48h). Closes the residue window: when any future ingest-side gate is tightened, pre-deploy entries stop shipping in briefs even before they TTL-expire from the accumulator. Rows missing publishedAt (legacy pre-this-PR entries) fall through — back-compat. Operator kill switch: DIGEST_READ_MAX_AGE_HOURS=999. 2. Persist publishedAt in story:track:v1 HSET (list-feed-digest.ts). Adds 'publishedAt', String(item.publishedAt) to buildStoryTrackHsetFields. Required for #1 AND for U6's age-mode (#3) to actually find the field. Pre-existing rows pick it up on their next mention. Test: tests/news-story-track-description-persistence.test.mts asserts the field is emitted as a numeric string that round-trips through parseInt. 3. U6 audit gains --mode=age|url|both (audit-static-page-contamination.mjs). Original URL-pattern classifier is BLIND to Google-News-routed entries (track.link is news.google.com/rss/articles/CBMi opaque redirect). --mode=age matches by track.publishedAt > max-age-hours, the right primary signal for evicting post-deploy residue. Per-reason rollup surfaces url vs age hits. Operator runbook included in the script header. Default --mode=url is back-compat. 4. Feed swap: 3 of 5 gov sources to direct first-party RSS (_feeds.ts). Verified 2026-04-26 via live curl + end-to-end parseRssXml smoke test: - Pentagon -> https://www.war.gov/DesktopModules/ArticleCS/RSS.ashx?ContentType=1&Site=945 - White House (briefings) -> /briefings-statements/feed/ - White House Actions -> /presidential-actions/feed/ (NEW entry) Direct RSS means the publisher's pubDate is authoritative — no re-indexing of months-old PDFs creating fresh-looking Google entries. State Dept, Treasury, DOJ retain gn(...) with an explanatory comment; no working public RSS at any verified path, Federal Register fallback bot-blocked. The READ-time freshness floor (#1) mitigates the residue gap for those. 5. source-tiers.json: White House Actions = 1 (with scripts/shared mirror). New feed gets the same tier-1 source-boost as the existing White House entry; without it, Actions items would default to tier-4 with no boost. 250/250 tests pass; importance-score-parity preserved. Operator runbook for the immediate post-deploy residue: 1. Wait ~1h for cron tick to start writing publishedAt on existing entries via HSET re-mention. 2. Dry run: node scripts/audit-static-page-contamination.mjs --mode=age 3. Inspect output, confirm matched titles look stale. 4. Apply: --mode=age --apply * fix: address review findings — window-aware floor + residue audit mode (P1×2 + P2/P3) 5 fixes responding to review: P1 (reviewer): READ-time floor + audit --mode=age both fall through on missing publishedAt — the legacy state of EVERY pre-PR-3422 row. So the actual residue this PR was meant to evict (Pentagon items in brief 2026-04-26-0802) wasn't catchable by either gate. Add --mode=residue that matches rows with missing/unparseable publishedAt — the explicit opt-in cleanup signal for the post-deploy one-shot. Operator runbook updated: wait at least 1 cron cycle (so still-active rows have publishedAt re-mentioned in via HSET), then run --mode=residue --apply. P1 (self-review): READ-time floor was hardcoded 48h, breaking weekly users (their 7d window stories all dropped). Replaced with readTimeAgeCutoffMs(windowStartMs) = windowStart - 24h buffer. Daily user (24h window) -> 48h cutoff (matches old behavior); weekly user (7d window) -> 8d cutoff (the broken case). DIGEST_READ_MAX_AGE_HOURS env var removed — there's no scenario where an operator wants to drop items younger than the rule's intended window. P2: Defensive cast in buildStoryTrackHsetFields. Number.isFinite guard on item.publishedAt prevents the literal "undefined"/"NaN" string from ever being persisted; degraded input writes '' which the read-side parseInt treats as legacy back-compat (fall through, not mis-classify as a stale-row). P2: Extract shouldDropTrackByAge + readTimeAgeCutoffMs into scripts/lib/digest-orchestration-helpers.mjs (the existing module for this purpose). 6 new unit tests cover the predicate matrix (within/at-cutoff/before/missing/unparseable/zero/null-track) plus 2 integration cases that reproduce the exact failure modes: - weekly user with 5d-old story is KEPT (window-aware vs naive 48h) - 4-month-old residue (Jan 2026 Pentagon PDF) is DROPPED for daily user P3: Audit parseArgs now rejects unknown args (--mode age space-not-equals) with exit-2 + clear error message. classifyTrack refactored to a pure function (mode + maxAgeMs as options) so tests can exercise it without the script's module-load argv side effects. main() invocation gated behind import.meta.url === file://process.argv[1] check (standard ESM idiom). 10 new unit tests cover the url/age/residue/both modes + parseArgs flag handling. 316/316 tests pass; importance-score-parity preserved. * fix(audit): residue mode safety gate + EOF whitespace (review fixes on #3422) P2 (reviewer): --mode=residue --apply was too broad. Classifier matched ANY row with missing publishedAt — but every pre-PR-3422 row lacks the field, so the recommended one-shot cleanup could delete legitimate recent stories that simply hadn't been re-mentioned in the first post-deploy cron cycle. Fix: residue mode now requires BOTH conditions: 1. publishedAt missing/unparseable (legacy or anomalous), AND 2. lastSeen >= residueMinStaleMs ago (default 24h). Active stories get HSET-touched on every re-mention, so a row that hasn't been touched in 24h is genuinely abandoned — the new ingest gate (when:1d) is correctly excluding it. Fresh-lastSeen rows are PROTECTED from deletion. New flag: --residue-min-stale-hours=N (default 24, positive-int only). Operator runbook updated: 1. Wait >=24h post-deploy. 2. Dry run: node scripts/audit-static-page-contamination.mjs --mode=residue 3. Inspect output, confirm rows are stale + look like residue. 4. Apply: --mode=residue --apply Defensive fall-throughs: - Missing lastSeen: treat as ancient (errs toward eviction). Acceptable in this opt-in destructive mode; rows without lastSeen are anomalous. - publishedAt present (any value): NOT residue (caught by --mode=age). Tests: - 8 residue-mode cases incl. the explicit P2 fix ("fresh lastSeen must protect the row"), boundary at exactly the threshold, missing-lastSeen treated as ancient. - 4 parseArgs cases for the new --residue-min-stale-hours flag. P3 (reviewer): tests/digest-orchestration-helpers.test.mjs had a trailing blank line at EOF. Stripped. 323/323 tests pass; importance-score-parity preserved. * fix(audit): align residue staleness gate with max digest window (P2 round-2) Reviewer flagged that --mode=residue --apply with the 24h staleness default would delete legitimate weekly-user stories. A pre-PR-3422 row that lacks publishedAt with lastSeen 2-7d ago is still ship-able for a weekly digest (whose readTimeAgeCutoffMs in digest-orchestration-helpers.mjs is windowStart - 24h = 8d ago) — but the 24h gate would mark it as residue and delete it. Fix: default --residue-min-stale-hours from 24 to 192 (= 7d max digest window + 24h buffer). Aligns with the readTimeAgeCutoffMs formula so residue mode can never delete a row still legitimately ship-able for ANY user. New explicit regression test: "SAFETY: does NOT match weekly-user 5d-old story" — pre-fix would have failed (deleted the row); now PROTECTED. Operators with confidence the fleet is daily-only can drop to faster cleanup via --residue-min-stale-hours=48 (documented escape hatch in the script header runbook + test case). The other safety guards remain: - publishedAt present → not residue (caught by --mode=age, not residue). - lastSeen fresh (< gate) → protected. - missing lastSeen → ancient (errs toward eviction in opt-in path). 325/325 tests pass; importance-score-parity preserved.
F2 — Cache prefix bump for happy-all-items: data-loader.ts:3200 HAPPY_ITEMS_CACHE_KEY bumped 'happy-all-items' → 'happy-all-items:v2' to match the feed: → feed:v2: invalidation. Pre-v2 entries serialize NewsItem without pubDateMissing; under the new helper they would fraudulently claim freshness inside the 24h gate window. Bumping forces refetch; pre-v2 entries are left to TTL out naturally. F3 — Velocity excludes missing-date items from trend math: velocity.ts pre-filters items.filter(i => !i.pubDateMissing) before the midpoint partition. Without this, clustering.ts:116 (allow-listed raw aggregation) would compute firstSeen/lastUpdated from synthesized stamps, then velocity would treat every missing-date item as 'older' via effectivePubDateMs=0, flipping cluster trend to 'falling' for any cluster with several missing-date items. F4/F5 — CI workflow timeouts: feed-validation.yml job gains timeout-minutes: 15 (cap on hung npm ci or stalled feed registry). digest-image job in test.yml gains timeout-minutes: 20 (cap on docker build infra flakes). F6 — Per-hop fetch timeout in --ci validator: validate-rss-feeds.mjs --ci redirect handler now creates a fresh AbortController + setTimeout per hop instead of one budget across all hops. "Timeout (15s)" in the report now means a single 15s network call, not 17s+ chained. F7 — MAX_HOPS off-by-one in --ci validator: Loop bound changed from `hop <= MAX_HOPS` to `hop < MAX_HOPS` so the documented "max 3 hops" intent matches the actual iteration count. Final-iteration redirect now throws 'Too many redirects' consistently. F8 — Dublin Core <dc:date> fallback in RSS parser: rss.ts:267-274 adds dc:date as a final fallback for the RSS branch (after pubDate). ArXiv RSS feeds — "ArXiv AI" and "ArXiv ML" in the feed registry — ship dc:date with no <pubDate>; without this fallback every ArXiv item would be marked pubDateMissing=true and demoted in every freshness ranking. Prior precedent: PR #3417 dc:date strict-drop incident. Observation 1 — Asymmetric tie-break in breaking-news-alerts.ts: Captures a bestEffectiveMs local so the tie-break comparison is symmetric (helper on both sides). Today isRecent excludes missing- date items upstream so the comparison is safe; the local makes the guarantee defense-in-depth against a future refactor that removes the upstream gate. Observation 2 — Document NaN/Infinity exclusion in time-range filters: data-loader.ts filterItemsByTimeRange and panel-layout.ts time-range filter previously wrapped raw pubDate.getTime() with Number.isFinite and fell through to `true` for non-finite, INCLUDING corrupt-stamp items in narrow windows. The helper now sanitizes NaN/Infinity to 0 uniformly with pubDateMissing — corrupt-stamp items are excluded. This is intentional (untrustworthy timestamps shouldn't claim freshness) but undocumented in U3; comments at both sites now call out the behavior shift. Plan: docs/plans/2026-05-23-001-fix-digest-pipeline-review-actions-plan.md
…identity (#3872) * ci(digest): add digest-image build job + scheduled feed-validation workflow PR CI gains a docker-build smoke for Dockerfile.digest-notifications that catches cross-directory-import + native-dep breakage the static BFS test in tests/dockerfile-digest-notifications-imports.test.mjs cannot see. Gated on path changes under scripts/, shared/, server/_shared/, api/, the Dockerfile itself, or the root package manifest. Feed-registry validation moves OFF pull_request CI (SSRF surface — a hostile PR could rewrite src/config/feeds.ts to make runners hit arbitrary URLs) and INTO a new feed-validation.yml that runs on push-to-main, every-6h schedule, and workflow_dispatch. The new test:feeds:ci script passes --ci to scripts/validate-rss-feeds.mjs which enforces https-only, allowlist-membership (via api/_rss-allowed-domain-match.js, the new extracted predicate the Edge rss-proxy.js also consumes), and per-hop redirect re-checks. Plan: docs/plans/2026-05-23-001-fix-digest-pipeline-review-actions-plan.md * test(railway): registry as single source of truth for service entrypoints Replaces the hand-maintained ENTRY_POINTS array in tests/scripts-railway-nixpacks-no-escape-import.test.mts (4 entries, already known to drift per PR #3836 retrospective) with a derived list from scripts/railway-services.json. The Dockerfile.digest-notifications BFS test reads the same registry filtered to deployMode === "dockerfile". A new coverage test (tests/railway-services-registry-coverage.test.mts) audits every Dockerfile.* CMD line and every runbook "Start command:" entry against the registry — if a deployment artifact references a script that isn't registered, the test fails naming the file. Self- fixtures pin both regex shapes so a future simplification can't silently stop matching. The registry surfaces 17 nixpacks services (up from 4) and 4 Dockerfile- deployed services. The runbook header now points new-service authors at the registry as the first stop. Plan: docs/plans/2026-05-23-001-fix-digest-pipeline-review-actions-plan.md * fix(rss): exclude missing-date items from freshness via effectivePubDateMs The legacy parseFeedDateOrNow silently substituted Date.now() for any missing/invalid feed pubDate, which produced false freshness across every ranking and recency gate. The fix replaces it with parseFeedDate (returns { date, missing }) and a new effectivePubDateMs helper that returns 0 for items flagged pubDateMissing — so they sort last in newest-first comparators and fail any positive-duration recency gate (Date.now() - 0 = huge), while keeping the synthesized pubDate in place for display consumers. Routed through the helper: - rss.ts AI-candidate top-N selection + min-heap insert (lines 296, 352-369) - daily-market-brief.ts headline-pool sort (line 217) - breaking-news-alerts.ts isRecent recency gate + best-alert ranking (lines 145, 260) - data-loader.ts staleItems sort, hero/digest sorts, time-range filter (6 sites) - panel-layout.ts time-range filter (caught by the new guardrail audit) - velocity.ts trend-midpoint partition (lines 58-59) - analysis-core.ts cluster sort + WINDOW_MS recency gate (lines 292, 440) Cache prefix bumped feed: → feed:v2: at rss.ts:23, 44-65 because pre-v2 serialized items lack pubDateMissing; old entries are left to TTL out. Two new tests: - tests/feed-date.test.mts — parser shapes + helper ranking behavior - tests/feed-date-ranking-uses-effective.test.mts — static audit that walks src/services/ + src/app/ and fails if any .pubDate.getTime() inside a ranking/recency call site bypasses the helper, with a documented allow-list for metadata/identity uses and self-fixtures pinning both sort-comparator and recency-gate regex shapes Plan: docs/plans/2026-05-23-001-fix-digest-pipeline-review-actions-plan.md * fix(rss): use link-keyed identity in AI classify queue dedupe Previously canQueueAiClassification(title) collapsed every article sharing a wire headline into one dedupe slot, so only the first got an AI threat upgrade and the rest stayed at keyword confidence for the 30-minute window. The new signature canQueueAiClassification({ link, title }) keys on the normalized link when present (lowercased host, fragments dropped, utm_*/fbclid/gclid stripped) and falls back to title when link is empty or malformed. Behavioral surface is narrower than it might look: the caller in rss.ts only invokes the queue for items where threat.source === 'keyword' — items already keyword-classified that are eligible for an AI confidence upgrade. A dropped enqueue does NOT drop the article from the feed; it just leaves its threat label at keyword confidence. Also adds __resetAiClassifyQueueForTests() following the project's __…ForTests convention (insights-loader.ts:104) so the new test file can drive the module-scope dedupe map across scenarios deterministically. Import switched from '@/config' barrel to '@/config/variant' leaf to avoid pulling @/utils/proxy.ts (and its import.meta.env.DEV usage) into the node:test runner. Plan: docs/plans/2026-05-23-001-fix-digest-pipeline-review-actions-plan.md * fix(rss): close pubDate-getTime guardrail bypass + harden effectivePubDateMs Code-review caught a real regex bypass: src/app/country-intel.ts:297 ran `new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime()` over NewsItem[] from ctx.allNews — a freshness-ranking comparator that the U3 guardrail regex /\.pubDate\s*\.getTime\(\)/ did not match. The country-brief panel's news ordering would have surfaced missing-date items at the top despite the U3 fix. Three changes: 1. src/app/country-intel.ts:297 now routes through effectivePubDateMs. 2. PUBDATE_GETTIME_RE broadened to also match `item.pubDate?.getTime()` (optional chain, idiomatic in TS 4+) and `new Date(item.pubDate). getTime()` (the bypass the country-intel violation used). Self-fixtures pin all three shapes plus a negative-control fixture confirming the regex does NOT catch `new Date(item.endDate).getTime()`. Paren-wrapped, aliased, and destructured reads remain residual risk — documented in the regex docblock. 3. effectivePubDateMs now guards NaN/Infinity on every input branch (Date with Invalid value, NaN/Infinity number, unparseable string). Sort comparators on NaN have unspecified behavior per the JS spec; the previous numeric branch returned the value verbatim. Tests cover NaN, ±Infinity, and Invalid Date. Also minor cleanups from review: - Inverted JSDoc on isRecent clarified (`0 >= (large positive) = false`). - Misleading migration-fallback comment in rss.ts (claimed "older builds stored feeds as feed:v2:") corrected to describe the actual scope: language-scope migration carried forward from the pre-v2 era. Plan: docs/plans/2026-05-23-001-fix-digest-pipeline-review-actions-plan.md * fix(rss): apply 9 P2/observation findings from code review (LFG batch) F2 — Cache prefix bump for happy-all-items: data-loader.ts:3200 HAPPY_ITEMS_CACHE_KEY bumped 'happy-all-items' → 'happy-all-items:v2' to match the feed: → feed:v2: invalidation. Pre-v2 entries serialize NewsItem without pubDateMissing; under the new helper they would fraudulently claim freshness inside the 24h gate window. Bumping forces refetch; pre-v2 entries are left to TTL out naturally. F3 — Velocity excludes missing-date items from trend math: velocity.ts pre-filters items.filter(i => !i.pubDateMissing) before the midpoint partition. Without this, clustering.ts:116 (allow-listed raw aggregation) would compute firstSeen/lastUpdated from synthesized stamps, then velocity would treat every missing-date item as 'older' via effectivePubDateMs=0, flipping cluster trend to 'falling' for any cluster with several missing-date items. F4/F5 — CI workflow timeouts: feed-validation.yml job gains timeout-minutes: 15 (cap on hung npm ci or stalled feed registry). digest-image job in test.yml gains timeout-minutes: 20 (cap on docker build infra flakes). F6 — Per-hop fetch timeout in --ci validator: validate-rss-feeds.mjs --ci redirect handler now creates a fresh AbortController + setTimeout per hop instead of one budget across all hops. "Timeout (15s)" in the report now means a single 15s network call, not 17s+ chained. F7 — MAX_HOPS off-by-one in --ci validator: Loop bound changed from `hop <= MAX_HOPS` to `hop < MAX_HOPS` so the documented "max 3 hops" intent matches the actual iteration count. Final-iteration redirect now throws 'Too many redirects' consistently. F8 — Dublin Core <dc:date> fallback in RSS parser: rss.ts:267-274 adds dc:date as a final fallback for the RSS branch (after pubDate). ArXiv RSS feeds — "ArXiv AI" and "ArXiv ML" in the feed registry — ship dc:date with no <pubDate>; without this fallback every ArXiv item would be marked pubDateMissing=true and demoted in every freshness ranking. Prior precedent: PR #3417 dc:date strict-drop incident. Observation 1 — Asymmetric tie-break in breaking-news-alerts.ts: Captures a bestEffectiveMs local so the tie-break comparison is symmetric (helper on both sides). Today isRecent excludes missing- date items upstream so the comparison is safe; the local makes the guarantee defense-in-depth against a future refactor that removes the upstream gate. Observation 2 — Document NaN/Infinity exclusion in time-range filters: data-loader.ts filterItemsByTimeRange and panel-layout.ts time-range filter previously wrapped raw pubDate.getTime() with Number.isFinite and fell through to `true` for non-finite, INCLUDING corrupt-stamp items in narrow windows. The helper now sanitizes NaN/Infinity to 0 uniformly with pubDateMissing — corrupt-stamp items are excluded. This is intentional (untrustworthy timestamps shouldn't claim freshness) but undocumented in U3; comments at both sites now call out the behavior shift. Plan: docs/plans/2026-05-23-001-fix-digest-pipeline-review-actions-plan.md
Why this matters
Yesterday's daily brief (
2026-04-25-2001) for a paying user shipped three Pentagon static institutional pages — "About Section 508", "Acquisition Transformation Strategy", and a 5G ecosystem report — interleaved with real news. Static landing pages have no publication date and no news-ness; they should never have entered the ingest pipeline at all.User-facing impact: brief reads as if Pentagon publishes daily strategy reports as breaking news. Trust hit for paying users.
What changed
This is PR 1/4 of the hardening pass scoped to closing the five compounding ingest-side bugs. The remaining three PRs follow per
docs/plans/2026-04-26-001-fix-brief-static-page-contamination-plan.md.U1 — every
gn(...)feed now has awhen:Ndtime gate11 ungated Google News–backed feeds were quietly returning any indexed page on the target domain. Now gated:
when:1dwhen:3dLayoffs Newsfeed cadencewhen:7dA static-analysis test (
tests/feeds-time-gate.test.mts) regression-locks everygn()call against future drift.U2 — RSS items missing a real
pubDateare dropped at parse timePreviously, when an RSS item lacked a publication date,
publishedAtfell back toDate.now()— making static pages appear "just published" and earn the full 10-pt recency bonus in the importance score. Two-step gate now:(a) Expanded date-tag set. RSS now recognizes
<pubDate>→<dc:date>→<dc:Date.Issued>→<published>; Atom recognizes<published>→<updated>→<dc:date>→<dc:Date.Issued>. Without this expansion, a strict drop would silently zero out ArXiv-class feeds that publish via Dublin Core.(b) Strict drop. After exhausting every recognized tag, items with empty / unparseable / >1h-future dates are dropped (not stamped with
Date.now()).Per-feed health classification:
feedStatus = 'all-undated'(every parsed item dropped — silent-zeroing signal)feedStatus = 'partial-undated'(informational; some kept, some dropped)[digest] FEED_HEALTH_WARNING all-undated feed="..."console log so log aggregation can keyword-match the failure mode.parseRssXmlreturn shape changed fromParsedItem[] | nullto{ items, parsedTotal, droppedUndated } | null. Internal cache prefix bumpedrss:feed:v1:→rss:feed:v2:so legacy v1 array values can't be mistyped as the new struct during deploy. Old v1 entries TTL-expire in 1h.U3 — hard freshness floor (default 48h)
NEWS_MAX_AGE_HOURSenv override (with NaN/zero/negative all falling back to 48h). Applied before corroboration counting so a stale duplicate of a fresh story can't inflate the cluster's source count.Tests
tests/feeds-time-gate.test.mtsgn()literal carrieswhen:Nd; sanity bound on min counttests/news-rss-pubdate-required.test.mtstests/news-freshness-floor.test.mtsNEWS_MAX_AGE_HOURSresolver edge cases + boundary semanticstests/news-rss-description-extract.test.mtsparseRssXmlreturn shapetests/importance-score-parity.test.mjstests/news-story-track-description-persistence.test.mts76 tests across the modified surface, all green. Typecheck clean.
Operational notes
Rollback envs:
NEWS_MAX_AGE_HOURS=999effectively disables the U3 floor.Monitoring after deploy:
[digest] FEED_HEALTH_WARNING all-undated feed="..."lines. Each entry indicates a feed whose date dialect we still don't recognize (allowlist or extend). Expectation: zero or near-zero in steady state.story:track:v1:*rows no longer clusterpublishedAtat cron-tick time (the oldDate.now()fabrication signature).Deferred to follow-up PRs (per the plan):
+2 tier cap+ 3-sitev3→v4prefix bump (incl.scripts/ais-relay.cjs:3128-3131independent cache that the digest fix alone wouldn't reach).--applyrun (post-deploy ops; evicts residualstory:track:v1:*contamination).Plus a separate follow-up:
api/health.jsRedis-key wiring for'all-undated'(requires a newnews:feed-health:v1key + writer + classifier; bigger than U2's surgical fix). Until then, log aggregation keyword-match onFEED_HEALTH_WARNINGis the silent-zeroing signal.Test plan
curl -Ia Pentagon URL via the digest endpoint and confirm zerodefense.gov/About/...-shaped URLs in the responseFEED_HEALTH_WARNINGshould be empty or expected-onlypublishedAtclustered at cron-tick time