Files
worldmonitor/shared
Elie Habib 45da551d17 feat(brief): per-user composer writing brief:{userId}:{issueDate} (Phase 3a) (#3154)
* feat(brief): per-user composer writing brief:{userId}:{issueDate} (Phase 3a)

Phase 3a of docs/plans/2026-04-17-003. Produces the Redis-resident
envelopes that Phases 1 (renderer) and 2 (edge routes) already know
how to serve, so after this ships the end-to-end read path works
with real data.

Files:

- shared/brief-filter.{js,d.ts}: pure helpers. normaliseThreatLevel
  maps upstream 'moderate' -> 'medium' (contract pinned the union in
  Phase 1). filterTopStories applies sensitivity thresholds and caps
  at maxStories. assembleStubbedBriefEnvelope builds a full envelope
  with stubbed greeting/lead/threads/signals and runs it through the
  renderer's assertBriefEnvelope so no malformed envelope is ever
  persisted. issueDateInTz computes per-user local date via Intl
  with UTC fallback.

- scripts/seed-brief-composer.mjs: Railway cron. Reads
  news:insights:v1 once, fetches enabled alert rules via the
  existing /relay/digest-rules endpoint (same set
  seed-digest-notifications uses), then for each rule computes the
  user's local issue date, filters stories, assembles an envelope,
  and SETEX brief:{userId}:{issueDate} with 7-day TTL. Respects
  aiDigestEnabled opt-in. Honours SIGTERM. Exits non-zero when >5%
  of rules fail so Railway surfaces structural breakage.

- Dockerfile.seed-brief-composer: standalone container. Copies the
  minimum set (composer + shared/ contract + renderer validator +
  Upstash helper + seed-envelope unwrapper).

- tests/brief-filter.test.mjs: 22 pure-function tests covering
  severity normalisation (including 'moderate' alias), sensitivity
  thresholds, story cap, empty-title drop, envelope assembly passes
  the strict renderer validator, tz-aware date math across +UTC/-UTC
  offsets with a bad-timezone fallback.

Out of scope for this PR:
- LLM-generated whyMatters / lead / signals (Phase 3b).
- brief_ready event fan-out to notification-relay (Phase 3c).
- Dashboard panel that consumes /api/latest-brief (Phase 4).

Pre-merge runbook:
1. Create a new Railway service from Dockerfile.seed-brief-composer.
2. Set env vars (UPSTASH_*, CONVEX_URL, RELAY_SHARED_SECRET) — reuse
   the values already in the digest service.
3. Add a cron schedule (suggested: hourly at :05 so it lands between
   the insights-seeder tick and the digest cron).
4. Verify first run: check service logs for
   "[brief-composer] Done: success=X ..." and a reader's
   /api/latest-brief should stop returning 'composing' within one
   cron cycle.

Tests: 72/72 (22 brief-filter + 30 render + 20 HMAC). Typecheck +
lint clean. Composer script parses with node --check.

* fix(brief): aiDigestEnabled default + per-user rule dedupe

Addresses two fourth-round review findings on PR #3154.

1. aiDigestEnabled default parity (todo 224). Composer was checking
   `!rule.aiDigestEnabled`, which skips legacy rules that predate the
   optional field. The rest of the codebase defaults it to true
   (seed-digest-notifications.mjs:914 uses `!== false`;
   notifications-settings.ts:228 uses `?? true`; the Convex setter
   defaults to true). Flipped the composer to `=== false` so only an
   explicit opt-out skips the brief.

2. Multi-variant last-write-wins (todo 225). alertRules are
   (userId, variant)-scoped but the brief key is user-scoped
   (brief:{userId}:{issueDate}). Users with the full+finance+tech
   variants all enabled would produce three competing writes with a
   nondeterministic survivor. Added dedupeRulesByUser() that picks
   one rule per user: prefers 'full' variant, then most permissive
   sensitivity (all > high > critical), tie-breaking on earliest
   updatedAt for stability across input reordering. Logs the
   occurrence so we can see how often users have multi-variant
   configs.

Also hardened against future regressions:

- Moved env-var guards + main() call behind an isMain() check
  (feedback_seed_isMain_guard). Previously, importing the script
  from a test would fire process.exit(0) on the
  BRIEF_COMPOSER_ENABLED=0 branch and kill the test runner. Tests
  now load the file cleanly.

- Exported dedupeRulesByUser so the tests can exercise the selection
  logic directly.

- The new tests/brief-composer-rule-dedup.test.mjs includes a
  cross-module assertion that seed-digest-notifications.mjs still
  reads `rule.aiDigestEnabled !== false`. If the digest cron ever
  drifts, this test fails loud — the brief and digest must agree on
  who is eligible.

Tests: 83/83 (was 72; +6 dedupe cases + 5 aiDigestEnabled parity
cases). Typecheck + lint clean.

* fix(brief): dedupe order + failure-rate denominator

Addresses two fifth-round review findings on PR #3154.

1. Dedupe was picking a preferred variant BEFORE checking whether it
   could actually emit a brief (todo 226). A user with
   aiDigestEnabled=false on 'full' but true on 'finance' got skipped
   entirely; same for a user with sensitivity='critical' on 'full'
   that filters to zero stories while 'finance' has matching content.

   Replaced dedupeRulesByUser with groupEligibleRulesByUser: pre-
   filters opted-out rules, then returns ALL eligible variants per
   user in preference order. The main loop walks candidates and
   takes the first one whose story filter produces non-empty content.
   Fallback is cheap (story filter is pure) and preserves the 'full'-
   first + most-permissive-sensitivity tie-breakers from before.

   dedupeRulesByUser is kept as a thin wrapper for the existing tests;
   new tests exercise the group+fallback path directly (opt-out +
   opt-in sibling, all-opted-out drop, ordering stability).

2. Failure gate denominator drifted from numerator (todo 227). After
   dedupe, `failed` counts per-user but the gate still compared to
   pre-dedupe rules.length. 60 rules → 10 users → 2 failed writes =
   20% real failure hidden behind a 60-rule denominator.

   Fix: denominator is now eligibleUserCount (Map size after
   group-and-filter). Log line reports rules + eligible_users +
   success + skipped_empty + failed + duration so ops can see the
   full shape.

Tests: 86/86 (was 83; +3 new: opt-out+sibling, all-opted-out drop,
candidate-ordering). Typecheck clean, node --check clean, biome clean.

* fix(brief): body-POST SETEX + attempted-only failure denominator

Addresses two sixth-round review findings on PR #3154.

1. Upstash SETEX (todo 228). The previous write path URL-encoded the
   full envelope into /setex/{key}/{ttl}/{payload} which can blow
   past proxy/edge/Node HTTP request-target limits for realistic
   12-story briefs (5-20 KB JSON). Switched to body-POST via the
   existing `redisPipeline` helper — same transport every other
   write in the repo uses. Per-command error surface is preserved:
   the wrapper throws on null pipeline response or on a {error}
   entry in the result array.

2. Failure-rate denominator (todo 229). Earlier round switched
   denominator from pre-dedupe rules.length to eligibleUserCount,
   but the numerator only counts users that actually reached a
   write attempt. skipped_empty users inflate eligibleUserCount
   without being able to fail, so 4/4 failed writes against 100
   eligible (96 skipped_empty) reads as 4% and silently passes.
   Denominator is now `success + failed` (attempted writes only).

Extracted shouldExitNonZero({success, failed}) so the denominator
contract lives in a pure function with 7 test cases:
- 0 failures → no exit
- 100% failure on small volume → exits
- 1/20 at exact 5% threshold → exits (documented boundary)
- 1/50 below threshold → no exit
- 2/10 above Math.max(1) floor → exits
- 1/1 single isolated failure → exits
- 0 attempted (no signal) → no exit

Tests: 93/93 (was 86; +7 threshold cases). Typecheck + lint clean.
2026-04-18 08:45:02 +04:00
..