Skip to content

fix(news-ingest): drop static institutional pages before they reach the brief (PR 1/4)#3417

Merged
koala73 merged 3 commits into
mainfrom
fix/brief-static-page-contamination
Apr 25, 2026
Merged

fix(news-ingest): drop static institutional pages before they reach the brief (PR 1/4)#3417
koala73 merged 3 commits into
mainfrom
fix/brief-static-page-contamination

Conversation

@koala73

@koala73 koala73 commented Apr 25, 2026

Copy link
Copy Markdown
Owner

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 a when:Nd time gate

11 ungated Google News–backed feeds were quietly returning any indexed page on the target domain. Now gated:

Feed Gate Cadence rationale
AP News, Reuters World/US/Business (×2), White House, State Dept, Pentagon, Treasury, DOJ when:1d Daily-cadence news + gov press releases
Layoffs.fyi when:3d Matches existing Layoffs News feed cadence
Bellingcat, Arms Control Assn, Bulletin of Atomic Scientists when:7d Investigative / think-tank slow cadence

A static-analysis test (tests/feeds-time-gate.test.mts) regression-locks every gn() call against future drift.

U2 — RSS items missing a real pubDate are dropped at parse time

Previously, when an RSS item lacked a publication date, publishedAt fell back to Date.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)
  • Structured [digest] FEED_HEALTH_WARNING all-undated feed="..." console log so log aggregation can keyword-match the failure mode.

parseRssXml return shape changed from ParsedItem[] | null to { items, parsedTotal, droppedUndated } | null. Internal cache prefix bumped rss: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_HOURS env 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

File Cases What it locks down
tests/feeds-time-gate.test.mts 2 Every gn() literal carries when:Nd; sanity bound on min count
tests/news-rss-pubdate-required.test.mts 16 Date-tag priority list, drop conditions, partial-feed preservation
tests/news-freshness-floor.test.mts 10 NEWS_MAX_AGE_HOURS resolver edge cases + boundary semantics
tests/news-rss-description-extract.test.mts 16 Existing tests; updated 3 cases for the new parseRssXml return shape
tests/importance-score-parity.test.mjs passes Formula untouched; relay parity preserved
tests/news-story-track-description-persistence.test.mts passes story:track:v1 contract unchanged

76 tests across the modified surface, all green. Typecheck clean.

Operational notes

Rollback envs:

  • NEWS_MAX_AGE_HOURS=999 effectively disables the U3 floor.
  • U1 + U2 require code-revert to roll back; the changes are not env-gated by design (the contamination they prevent is not acceptable risk to leave on a flag).

Monitoring after deploy:

  • 24h: audit Railway logs for [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.
  • 7d: confirm story:track:v1:* rows no longer cluster publishedAt at cron-tick time (the old Date.now() fabrication signature).

Deferred to follow-up PRs (per the plan):

  • PR 2/4 — U4: LLM classify-cache +2 tier cap + 3-site v3→v4 prefix bump (incl. scripts/ais-relay.cjs:3128-3131 independent cache that the digest fix alone wouldn't reach).
  • PR 3/4 — U5+U6+U7: brief-side source-topic cap + shared URL classifier helper + brief-filter denylist.
  • PR 4/4 — U6 audit script --apply run (post-deploy ops; evicts residual story:track:v1:* contamination).

Plus a separate follow-up: api/health.js Redis-key wiring for 'all-undated' (requires a new news:feed-health:v1 key + writer + classifier; bigger than U2's surgical fix). Until then, log aggregation keyword-match on FEED_HEALTH_WARNING is the silent-zeroing signal.

Test plan

  • CI green (typecheck, lint, all suites)
  • After Vercel deploy: curl -I a Pentagon URL via the digest endpoint and confirm zero defense.gov/About/...-shaped URLs in the response
  • 24h post-deploy: Railway logs grep FEED_HEALTH_WARNING should be empty or expected-only
  • 24h post-deploy: random spot-check on 3 user briefs — no static pages, no items with publishedAt clustered at cron-tick time

…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
@vercel

vercel Bot commented Apr 25, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Apr 25, 2026 10:07pm

Request Review

@greptile-apps

greptile-apps Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This 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 gn() feeds, enforces strict pubDate dropping with an expanded Dublin Core tag set, and adds a configurable 48h freshness floor applied before corroboration counting.

  • P1 — 'all-undated' feedStatus is dead code: parseRssXml returns null when every item is dropped (line 339), causing fetchAndParseRss to return the zero-stats fallback { parsedTotal: 0, droppedUndated: 0 }. The guard result.parsedTotal > 0 in buildDigest (line 724) is therefore always false — all-undated feeds are silently classified as 'empty' instead. The FEED_HEALTH_WARNING console.warn inside parseRssXml still fires correctly, so log-aggregation monitoring works, but any downstream consumer of feedStatuses checking for 'all-undated' will never see it.

Confidence Score: 3/5

Safe 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

Filename Overview
server/worldmonitor/news/v1/list-feed-digest.ts Core ingest hardening (U2 date-gate, U3 freshness floor, ParseResult shape); contains a P1 bug where the 'all-undated' feedStatus classification is unreachable dead code — all-undated feeds are silently misclassified as 'empty'
server/worldmonitor/news/v1/_feeds.ts Adds when:Nd time-gates to 11 previously ungated gn() calls; straightforward and correct
tests/feeds-time-gate.test.mts New static-analysis regression test that reads _feeds.ts source and asserts every gn() call carries a when:Nd clause; solid regression lock
tests/news-freshness-floor.test.mts Comprehensive edge-case and boundary tests for resolveMaxAgeMs; covers NaN, zero, negative, empty-string, and boundary semantics correctly
tests/news-rss-description-extract.test.mts Mechanical update of 3 test cases to destructure result.items from the new ParseResult return shape; correct
tests/news-rss-pubdate-required.test.mts Good coverage of date-tag priority, drop conditions, and partial-feed preservation; the all-undated test documents the stats-loss but the test name contains a misleading assertion about non-existent detection logic in fetchAndParseRss

Sequence Diagram

sequenceDiagram
    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
Loading

Comments Outside Diff (1)

  1. tests/news-rss-pubdate-required.test.mts, line 771-784 (link)

    P2 Test documents the data-loss but doesn't assert the classification consequence

    The test name claims "caller uses parsedTotal===0 + droppedUndated>0 detection in fetchAndParseRss", but no such detection exists in fetchAndParseRss; the stats are silently zeroed by the ?? { items: [], parsedTotal: 0, droppedUndated: 0 } fallback. This is the same issue as the dead 'all-undated' branch in buildDigest — the test documents the symptom but doesn't assert the downstream mis-classification so it won't catch a future regression either way. Consider asserting that the fallback result has parsedTotal === 0 and explaining that this means buildDigest will classify the feed as 'empty', not 'all-undated', until the stats-propagation fix is implemented.

Reviews (1): Last reviewed commit: "fix(news-ingest): drop static institutio..." | Re-trigger Greptile

Comment on lines +724 to +725
if (result.parsedTotal > 0 && result.items.length === 0 && result.droppedUndated > 0) {
feedStatuses[feed.name] = 'all-undated';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 '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.
@koala73
koala73 merged commit ace3819 into main Apr 25, 2026
10 checks passed
@koala73
koala73 deleted the fix/brief-static-page-contamination branch April 25, 2026 22:17
koala73 added a commit that referenced this pull request Apr 25, 2026
…+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
koala73 added a commit that referenced this pull request Apr 26, 2026
…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.
koala73 added a commit that referenced this pull request Apr 26, 2026
…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.
koala73 added a commit that referenced this pull request May 23, 2026
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
koala73 added a commit that referenced this pull request May 23, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant