Files
news/.omo/notepads/news-triage/learnings.md
T
2026-09-01 18:44:42 -04:00

42 KiB

Learnings — news-triage

Conventions, patterns, and successful approaches discovered during work on this plan.

Auto-scaffolded by /start-work. Append new entries below - never overwrite.


Todo 1 — workspace scaffold

  • Toolchain pin: rust-toolchain.toml pinned to channel = "1.89.0" (exact rustc --version on this box), with components = ["rustfmt", "clippy"] and targets = ["wasm32-unknown-unknown"]. First cargo build auto-downloaded the 1.89.0 toolchain via rustup (~35s one-time cost) even though 1.98.0 was already the machine's default (matches runway's pin) — this is expected and correct: rust-toolchain.toml overrides the ambient default per-directory.
  • cargo-machete false "unused" at scaffold stage: with only stub lib.rs/main.rs files (doc comments only, no code using serde/ thiserror/news-core), cargo machete correctly flags every declared dep as unused and exits 1. This is EXPECTED, not a bug — it will self-resolve once todo 2 adds serde-derived types to news-core. Do not "fix" this by removing the deps; they're scoped per this todo's explicit allowance (serde + thiserror only).
  • cargo-deny/cargo-machete already installed on this box at ~/.cargo/bin/ — no cargo install --locked needed locally (CI still installs them fresh per the workflow, matching runway's pattern).
  • cargo deny check emits license-not-encountered warnings (not failures) for licenses listed in deny.toml's allow-list that don't appear yet in the tiny dependency tree (Apache-2.0 WITH LLVM-exception, BSD-2-Clause, BSD-3-Clause, ISC, Zlib) — harmless, will resolve as more deps land; exit code stays 0.
  • news-web is a plain native binary stub (no Leptos dep yet) per the todo's explicit allowance ("minimal placeholder fn main() {} is fine"). It still passes cargo check -p news-web --target wasm32-unknown-unknown since it has zero non-wasm-safe dependencies — so the CI check job's wasm step already works even before Leptos is wired in (Wave 5 / todo 19).
  • Workspace-level [workspace.dependencies] kept to serde (derive feature)
    • thiserror per the todo's explicit "must not" constraint — every crate's own Cargo.toml has [lints] workspace = true to inherit the deny/forbid lint set, mirroring runway's per-crate convention exactly.

Todo 2 — news-core domain types

  • unwrap_used/expect_used deny is workspace-wide, including tests — confirmed against runway's convention: runway does NOT scope the lint exception to #[cfg(test)] mod tests blocks; it uses #![allow(clippy::unwrap_used, clippy::expect_used)] at the top of standalone tests/*.rs integration test files. For inline #[cfg(test)] mod tests inside src/model.rs (this todo's case, since the plan asked for the test in model.rs itself), the cleanest option that needs NO #[allow] at all is: write #[test] functions that return Result<(), serde_json::Error> and use the ? operator on the fallible serde_json::to_string/from_str calls, then assert_eq! for the actual behavioral check (which never panics via unwrap/expect — it uses assert_eq!'s own panic machinery, which is not linted). This avoids .unwrap()/.expect() entirely and keeps the workspace-wide deny intact with zero #[allow] anywhere. Use this pattern for all future crates' inline unit tests; reserve runway's #![allow(clippy::unwrap_used, clippy::expect_used)] file-level escape hatch only if a future test genuinely cannot be expressed as a Result-returning test (e.g. asserting a panic via #[should_panic], or needing Uuid::new_v4() fallibility that never actually fails).
  • uuid/chrono workspace deps added, mirroring runway's exact feature sets (uuid = { version = "1", features = ["v4", "serde"] }, chrono = { version = "0.4", default-features = false, features = ["serde", "clock", "std"] }). Also added serde_json and pretty_assertions to [workspace.dependencies] as dev-dependencies for round-trip tests — every crate with domain types will need these, so hoisting them to the workspace level now avoids repeated version decisions later.
  • This resolves the cargo-machete "unused serde" false positive flagged in todo 1: news-core now has real serde-derived types, so serde is genuinely used.

Todo 5 — FTS5 external-content table sync triggers (exact working SQL)

The standard SQLite FTS5 "external content table" trigger recipe (sqlite.org/fts5.html section 4.4.3), confirmed working against sqlite::memory: via sqlx 0.8.6 with three passing tests (insert-then-MATCH, delete-then-MATCH-absent, and the migrate-twice idempotency check):

CREATE VIRTUAL TABLE raw_items_fts USING fts5(
    title,
    summary,
    content = 'raw_items',
    content_rowid = 'rowid',
    tokenize = 'porter'
);

CREATE TRIGGER raw_items_ai AFTER INSERT ON raw_items BEGIN
    INSERT INTO raw_items_fts (rowid, title, summary)
    VALUES (new.rowid, new.title, new.summary);
END;

CREATE TRIGGER raw_items_ad AFTER DELETE ON raw_items BEGIN
    INSERT INTO raw_items_fts (raw_items_fts, rowid, title, summary)
    VALUES ('delete', old.rowid, old.title, old.summary);
END;

CREATE TRIGGER raw_items_au AFTER UPDATE ON raw_items BEGIN
    INSERT INTO raw_items_fts (raw_items_fts, rowid, title, summary)
    VALUES ('delete', old.rowid, old.title, old.summary);
    INSERT INTO raw_items_fts (rowid, title, summary)
    VALUES (new.rowid, new.title, new.summary);
END;

Key gotchas that would otherwise silently corrupt the index:

  • The 'delete' command is issued as an INSERT into the fts5 table itself, not a DELETE FROM raw_items_fts. The first column value must literally be the string 'delete' and the second must be the rowid to remove — this special form is documented in section 6.3 ("The 'delete' Command") and is what makes it different from a normal content mutation.
  • The delete/update triggers MUST pass old.title/old.summary (the values being removed) alongside old.rowid — omitting the column values or passing new.* there is the single most common mistake and either throws an FTS5 "constraint failed" error or leaves stale index entries.
  • UPDATE is delete-then-insert (two separate INSERT INTO raw_items_fts statements in one trigger body), not a single "update" special command — FTS5 has no special update verb; the delete+reinsert pattern is the documented approach.
  • PRAGMA journal_mode=WAL; has no effect on sqlite::memory: — it silently returns "memory" instead of "wal" from some drivers. Do not assert on the PRAGMA's return value in a test that runs against sqlite::memory:; init_db's test suite intentionally does not check it (per the plan's explicit note on this).
  • sqlx 0.8 (not 0.9) is required under this workspace's rust-toolchain.toml pin of 1.89.0 — see decisions.md for the MSRV details. cargo test fails fast with a clear "rustc X is not supported by sqlx@0.9.0" message if this is gotten wrong, so it's an easy fix to spot.

Todo 7 — feed poller XML namespace handling and op-ed heuristic

  • Namespace handling: literal tag-string matching, not NsReader. Both parsers (rss_atom.rs, news_sitemap.rs) use plain quick_xml::reader::Reader (not NsReader) and match element names as &str via QName/LocalName's AsRef<str> (quick-xml 0.42's QName wraps &str, not &[u8] — the API changed since older quick-xml versions that some examples online still show as byte-string b"tag" matching). For the AP news-sitemap, news:title and news:publication_date are matched as the literal strings "news:title"/"news:publication_date" (prefix included), which is correct and sufficient because AP's sitemap always declares the prefix as exactly news: — no namespace-aware resolution needed. If a future source ever used a different prefix for the same namespace URI, this approach would silently miss it; NsReader::read_resolved_event_into would be the fix at that point, resolving by namespace URI (http://www.google.com/schemas/sitemap-news/0.9) rather than prefix string. Chose literal matching over NsReader because it's simpler and every real AP/BBC/AJ sample uses the same fixed prefixes.
  • Op-ed heuristic (todo 9/12 should know this): ItemKind::Opinion is assigned in rss_atom.rs's item_kind_from_category() when the RSS <category> text (case-folded) contains the substring "opinion" or "comment"; everything else (including a missing <category>) defaults to ItemKind::News. Atom <category term="..."> values feed the same heuristic via the term attribute. The news-sitemap dialect has no per-item category field at all, so parse_news_sitemap() always emits ItemKind::News — AP items can never be classified Opinion by this crate; if AP op-ed detection matters later, it would need a title/URL-path heuristic (e.g. /opinion/ in the <loc> path) added at that time, since the sitemap protocol carries no signal for it today. This is a coarse heuristic (substring match on one field), not a content classifier — todo 9 (clustering) and later scoring todos should treat ItemKind::Opinion as a soft/approximate signal, not authoritative.
  • quick_xml 0.42 API notes (differs from older docs found online): BytesText has no .unescape() method in this version — use quick_xml::escape::unescape(text.into_inner().as_ref()) directly. Attribute::unescape_value() is deprecated in favor of Attribute::normalized_value(XmlVersion::Explicit1_0). Attributes iterates Result<Attribute, AttrError>, and AttrError converts to quick_xml::Error via From but not directly to a custom error type, so call sites need .map_err(quick_xml::Error::from)? before the ? propagates into a thiserror-derived IngestError that only has #[from] quick_xml::Error.
  • Retry-After: only the integer-seconds form is parsed (parse_retry_after in lib.rs); the HTTP-date form (Retry-After: Wed, 21 Oct 2026 07:28:00 GMT) is intentionally left unparsed (falls back to None) per the plan's explicit allowance to "document if you skip HTTP-date parsing" — todo 18's scheduler should not assume RateLimited(None) means "no rate limit signal was sent", since it also covers this unparsed-date case.
  • Live smoke test result: curl -sI against https://www.aljazeera.com/xml/rss/all.xml with the exact honest UA returned HTTP/2 200 with etag/last-modified response headers present — network access was available in this sandbox, so this is a real (not skipped) verification.
  • Unique-violation detection: the clean dedupe path is match on the sqlx::query(...).execute(...).await result and map Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() to InsertOutcome::AlreadyExists. Do not pre-SELECT — it is race-prone and slower, and the SQLite UNIQUE(source_id, link) constraint is the authoritative guard.
  • FTS5 auto-sync: never insert/update raw_items_fts directly. The external-content triggers in 0001_init.sql (raw_items_ai, raw_items_ad, raw_items_au) mirror raw_items into the FTS5 table automatically. Manual inserts would duplicate entries and corrupt ranking.
  • MATCH with a bound parameter is safe: WHERE raw_items_fts MATCH ?1 with sqlx::query(...).bind(query) treats the bound value as the literal query text, so arbitrary user input cannot break FTS5 syntax. Do not string-interpolate the query.
  • bm25() sign convention: SQLite's bm25() returns negative or small positive values where lower is better (e.g. a strong match can be -2.3, a weaker match closer to 0). ORDER BY rank therefore yields best-first. Callers that want a descending "higher is better" score (e.g. todo 10) must negate or otherwise invert the value.
  • External-content join: because raw_items_fts is declared with content = 'raw_items' and content_rowid = 'rowid', the FTS5 rowid is the same as raw_items.rowid. Join is JOIN raw_items r ON r.rowid = f.rowid; SELECT r.* then maps cleanly to the domain RawItem.
  • chrono must be declared in news-store/Cargo.toml even though the RawItem type lives in news-core. Serialization to RFC3339 strings (published_at.to_rfc3339()) and parsing back (DateTime::parse_from_rfc3339) happen inside news-store, so the dependency is required there.

Todo 9 — title-similarity clustering + ClusterRepo

  • Threshold chosen for tests: 0.5. The Jaccard token-set score between "Israel and Hamas agree ceasefire deal" and "Israel and Hamas agree ceasefire deal, mediators say" is 5/7 ≈ 0.714, well above 0.5; a looser title variant like "Israel and Hamas reach ceasefire agreement" scored only 3/7 ≈ 0.429 and therefore started its own cluster. 0.5 is a serviceable first cut for the breaking-bypass corroboration rule, but real-world evaluation may need calibration.
  • Window is anchored to the incoming item's timestamp, not Utc::now(). The candidate query uses sc.last_seen_at <= ?1 (the new item's published_at as RFC3339) and julianday(?1) - julianday(sc.last_seen_at) < 45.0 / 1440.0. Using the item's own timestamp makes the 46-minute failure-path test deterministic.
  • Strict inequality matters: < 45.0 / 1440.0 ensures a 46-minute gap is excluded; a <= would incorrectly merge across the boundary.
  • source_count must be COUNT(DISTINCT raw_items.source_id), not a count of member rows. Two items from the same source (e.g. AJ repeating the same event) must yield source_count == 1 until a different source joins.
  • All assignment writes belong in one sqlx transaction (pool.begin(), updates, tx.commit()). This prevents a crash between inserting a cluster_members row and updating story_clusters.source_count.
  • Clippy manual_contains: for a &[&str] slice, prefer STOPWORDS.contains(&token) over iter().any(|&s| s == token).

Todo 10 — RelevanceScorer: FTS5 BM25 + persisted Naive Bayes + blocklist veto

  • BM25 sign flip: SQLite's bm25() returns ascending-better ranks that are typically negative, so the scorer negates the raw rank to produce a descending higher-is-better score. Items not returned by the FTS query (empty interests or no match) are floored at 0.0. This keeps the BM25 term non-negative while preserving the ordering from ItemRepo::search_fts.
  • Tokenizer sharing: news_core::clustering::tokenize is now public and returns Vec<String> using the same rules as title-similarity clustering. similarity converts the vector to a HashSet so its behavior is unchanged; the scorer keeps duplicates so repeated words contribute multiple times to the Bayes log-odds sum.
  • Model persistence: the bayes_model table's singleton row (id = 1) is read on RelevanceScorer::load and written with INSERT OR REPLACE after every train. serde_json round-trips the model because NaiveBayesModel now derives Serialize and Deserialize.
  • Do not implement news_core::scoring::relevance::Bm25Source here. The trait is synchronous, but news-store/sqlx access is async. The scorer takes its BM25 signal directly from ItemRepo::search_fts; a sync adapter would require blocking an async runtime and is the wrong seam for todos 13/18.
  • Direct SQL needs a pool handle: ItemRepo keeps its SqlitePool field private, so RelevanceScorer retains its own pool: SqlitePool clone for the singleton-row read/write in load and train.
  • Blocklist veto is first and unconditional: case-insensitive substring match against title + " " + summary returns f64::NEG_INFINITY before BM25 or Bayes are computed. Training the model heavily on the item's own tokens as Interested does not override the veto.
  • Test naming: placing #[tokio::test] functions at module level (with #[cfg(test)] on each helper and test) yields paths like scoring::blocklist_veto_returns_neg_infinity, matching the expected test filter exactly.

Task 11 — PercentileTracker + ImportanceScorer

  • Keep distributions strictly separate: percentile_stats.metric_kind stores "importance" and "relevance" as TEXT discriminants, and every query filters on metric_kind. Todo 12 reads Importance@p99 and Relevance@p90 from independent samples; mixing them would break the gate.
  • RFC 3339 TEXT consistency: both writes and the cutoff comparison use Utc::now().to_rfc3339() (no to_rfc3339_opts). SQLite TEXT comparison then orders ISO timestamps correctly, so the computed_at >= cutoff filter is deterministic and precision-matched on both sides.
  • Window edge case: rows with computed_at older than window_days are excluded by the query, not by post-filtering. Inserting a row directly via SQL with an 8-day-old timestamp and querying with window_days = 7 proves the filter excludes it.
  • Empty window must return StoreError::InsufficientData, not a default 0.0 and not a panic. The InsufficientData variant already existed with needed/available fields; the tracker uses { needed: 1, available: 0 }.
  • Percentile indexing: with count sorted values, the value at index ceil(p / 100.0 * count) - 1 (clamped to count - 1) gives the expected exact results: 100 values 1.0..=100.0 at p90 returns 90.0, and p100 returns the maximum.
  • ImportanceScorer trust term: use the maximum Source.weight among the cluster's distinct member sources. The cluster may contain multiple items from the same source; COUNT(DISTINCT raw_items.source_id) is already cluster.source_count, and MAX(sources.weight) captures the strongest source in the corroboration set. v1 hard-codes prominence to 0 because RSS feeds carry no editorial rank signal.
  • ImportanceScorer does not record samples: recording cadence is the scheduler's job (todo 18). The scorer only computes the arithmetic score via news_core::scoring::importance::importance_score.
  • Direct SQL in news-server needs StoreError conversion: sqlx::Error must be mapped with .map_err(news_store::StoreError::from)? because ServerError only implements From<StoreError>, not From<sqlx::Error>.
  • Age parameter: the importance formula accepts corroboration_window_minutes for API completeness but does not use it numerically in v1. score_cluster still passes the cluster's age in minutes to keep the call site honest for future window-aware variants.

Todo 12 — NotificationGate

  • The plan sketch omitted the pool, but the gate needs it: NotificationGate must hold SqlitePool because every evaluate() call reads from and writes to notification_log (already-notified check, bypass ceiling count, and success logging). The struct is NotificationGate { pool, budget, config }.
  • Bypass lane is fully independent of the normal token bucket: bypass decisions neither consume nor refill TokenBucket.tokens; the acceptance test bypass_ceiling_is_independent_of_normal_budget proves this by pre-exhausting the bucket to 0.0 and still allowing 4 bypass Notify decisions before the 5th falls through to Suppress(BudgetExhausted).
  • In-memory token bucket is an accepted v1 limitation: TokenBucket stores tokens and last_refill in memory, so a process restart resets the budget. Persistence is intentionally out of scope for todo 12.
  • Corroboration bypass inference: cluster.source_count >= 2 qualifies a story for the bypass lane because StoryCluster members are grouped within a 45-minute window (clustering logic in news-ingest), so two sources means 2-of-N corroboration within that window.
  • Percentiles are 0-100 scale in the gate: notify_percentile = 90.0, digest_floor_percentile = 75.0, bypass_importance_percentile = 99.0. Tests use values like 95.0 and 80.0 directly; this matches the config defaults and avoids the 0-1 scale used elsewhere in sample data.
  • Quiet-hours window uses Louisville local hour: hour >= 22 || hour < 7 on now.with_timezone(&America::Louisville). Relevance in the digest band (75 <= r < 90) still produces Digest even during quiet hours; higher relevance during quiet hours produces Suppress(QuietHours).
  • Already-notified check runs before bypass: the latest notification_log row for the cluster is fetched first. If cluster.source_count < source_count_at_notify + material_update_source_delta, the evaluation returns Suppress(AlreadyNotified) before any bypass or normal-path logic.
  • RFC 3339 TEXT for DB reads/writes and cutoff comparison: created_at is stored with now_utc.to_rfc3339() and the 24-hour bypass cutoff uses (now_utc - Duration::hours(24)).to_rfc3339(). SQLite TEXT ordering is lexicographically correct for RFC 3339 timestamps.
  • No unwrap/expect in tests: chrono_tz::Tz::with_ymd_and_hms() returns a LocalResult, so tests use .single().ok_or_else(|| ServerError::DateTime(...))?. This required adding a ServerError::DateTime(String) variant.
  • Clippy collapsible_if on let/if chain: the already-notified check was cleaner as if let Some(...) = ...? && cluster.source_count < ....
  • Digest is a decision only: GateDecision::Digest does not persist digest content or queue entries; todo 16 owns digest content selection.

Todo 13 — POST /api/feedback + FeedbackRepo + incremental Naive Bayes retraining

  • FeedbackRepo mirrors sources.rs/items.rs: pub struct FeedbackRepo(SqlitePool) with new(pool), insert, and query helpers. kind_to_str maps FeedbackKind to snake_case TEXT exactly like ItemKind/SourceKind.
  • canonical_item_text uses fetch_optional: returns None when the cluster is missing, letting the handler return the same 400 as an unknown cluster id.
  • Json<FeedbackRequest> gives 422 for free: FeedbackKind already has #[serde(rename_all = "snake_case")]; an unknown variant like "bogus" fails deserialization and axum returns 422 JsonDataError with no custom parsing code.
  • RelevanceScorer::load(..., Vec::new(), Vec::new()) is fine for training: load() reads the persisted bayes_model row; train() only increments counts and writes the row back. Empty interests/blocklist are irrelevant because train() never consults them.
  • Incremental training only: each request calls scorer.train(&tokens, kind) once. The model is never rebuilt from the feedback table.
  • tower::ServiceExt::oneshot for in-process tests: build the Router, then app.oneshot(Request::builder()...body(...)?) with no TCP listener. In axum 0.8 oneshot returns Result<Response, Infallible>; handle the Infallible with an exhaustive match to satisfy the unwrap_used/expect_used deny.
  • Map HTTP/builder errors into ServerError in tests: Request::builder().body() and axum::body::to_bytes() can fail; map them to ServerError::DateTime (or propagate via ?) so tests return Result<(), ServerError> without unwrap/expect.
  • Keep axum/tower imports tidy: Body and Request are test-only, so they live inside #[cfg(test)] mod tests; otherwise dead_code/unused_imports warnings fire and -D warnings fails.

Todo 14 — NewsConfig TOML loader with SIGHUP hot-reload

  • Schema must match the draft character-for-character (.omo/drafts/news-triage.md:47): top-level [[sources]], [notify], [topics]; required source keys url, kind, weight, enabled; only poll_interval_secs is optional via #[serde(default)]. Missing required keys produce a hard TOML parse error.
  • Naming mismatches with gate::NotifyConfig (to resolve when wiring the gate in a later todo):
    • Config has quiet_hours_start/quiet_hours_end as "HH:MM" strings; NotifyConfig has quiet_start_hour/quiet_end_hour as u32. A parser will be needed to translate "22:00"22 and "07:00"7.
    • Config has budget_refill_per_day/budget_burst (f64) and bypass_ceiling_per_day (u32); NotifyConfig lacks budget fields (they feed TokenBucket::new) and calls the bypass limit bypass_daily_ceiling: u32.
    • NotifyConfig carries computed defaults not present in TOML: notify_percentile, digest_floor_percentile, bypass_importance_percentile, material_update_source_delta. These will likely stay hard-coded or move to a separate defaults module.
  • All-or-nothing reload: reload_once parses the file outside the lock, then acquires the write lock and replaces the value. A parse error returns Err without touching the in-memory config; the SIGHUP watcher logs the error and continues.
  • tracing_test::traced_test + logs_contain captures real logs in async tests. reload_once itself returns Err; the test logs that error with tracing::error! and asserts logs_contain("toml parse error"), mirroring what watch_sighup does in production.
  • No unwrap/expect in tests or production: tempfile::tempdir()?, std::fs::write(&path, ...)?, and load(&path)? all leverage the new ServerError::Io/ServerError::Toml #[from] conversions. The SIGHUP handler installation failure is handled with match rather than .expect() because the workspace denies expect_used.
  • Cargo workspace inheritance with additive features: promoting tokio to a regular dependency in news-server/Cargo.toml uses tokio = { workspace = true, features = ["macros", "rt", "signal", "sync"] }. Cargo adds these features to the workspace features (rt-multi-thread, macros) rather than replacing them.
  • Added to root [workspace.dependencies] only: toml, tracing, tracing-test, tempfile. No other root-table changes were made.

Todo 15 — NtfyPublisher

  • Action-button JSON bodies must be single-quoted in the Actions header: ntfy's simple action parser splits parameters on commas and actions on semicolons. A JSON body like {"story_cluster_id":"...","kind":"interested"} contains both, so it has to be wrapped in single quotes: body='{"story_cluster_id":"...","kind":"interested"}'.
  • Use reqwest's built-in auth helpers: .bearer_auth(token) and .basic_auth(user, Some(pass)) avoid hand-rolled base64 and InvalidHeaderValue errors.
  • Retry-once logic should be explicit, not a loop: one match for the first attempt, a 500 ms tokio::time::sleep, then a second attempt. This guarantees exactly one retry and no infinite loops.
  • NEWS_API_BASE_URL default belongs in the publisher: the ntfy notification needs a concrete API base URL for its http feedback actions. from_env reads NEWS_API_BASE_URL and falls back to http://localhost:3000.
  • Wiremock Mock::expect(n) verifies call counts on guard drop: hold the returned MockGuard until after the assertion so the expectation is not unregistered early. expect(2) plus respond_with(500) cleanly asserts the retry-test "exactly 2 requests" contract.
  • #[allow(dead_code)] on NtfyPublisher is acceptable when the spec requires stored fields that have no current reader: the struct must keep base_url and topic per the todo, even though only topic_url is used at runtime. The override is narrower than disabling the workspace lint globally.

Todo 16 — daily digest

  • PercentileTracker::percentile(metric_kind, p, days) returns the value at percentile p, not the percentile rank of a value: the task's pseudo-call percentile(MetricKind::Relevance, score) is semantically backwards against the existing API. The correct band check is to fetch the 75th and 90th percentile values once and test p75 <= score && score < p90, which is equivalent to checking that the score's rank falls in [75, 90).
  • DateTime with a generic TimeZone does not implement Display: the Tz: TimeZone generic used in build_and_send_digest prevents tracing::debug! with {}. Use structured debug fields (tracing::debug!(?now, "...")) or convert to DateTime<Utc> before formatting.
  • Digest idempotency relies on notification_log.lane = 'digest': a single row with a nil UUID cluster ID and source_count_at_notify set to the number of stories is sufficient; no token-bucket or cluster-specific log rows are needed.
  • NtfyPublisher retry logic can be factored without changing existing tests: extracting send_with_retry lets publish_digest share the one-retry behavior of publish while keeping the original tests passing unchanged.
  • RawItem.kind / raw_items.kind already distinguishes opinion pieces: no schema change was required to honor notify_opinions; the digest joins through to the canonical raw_items row and filters on ItemKind::Opinion.

Todo 17 — replay harness and server dry-run

  • SqlitePool::connect in this sqlx/sqlite build does not create the database file: the default NEWS_DB_PATH=./news.db must be pre-created as an empty file (and its parent directories) before init_db opens it; in-memory pools (sqlite::memory:) are unaffected.
  • Dry-run suppression isolation is enforced in SQL, not Rust: every suppression-relevant read of notification_log must include AND dry_run = 0. Missing it in even one subquery (e.g. bypass_count_last_24h) lets a dry-run row suppress a real evaluation.
  • Keep public wrapper APIs unchanged: adding evaluate_with_mode and build_and_send_digest_with_mode lets the new code use dry_run while all existing tests and call sites compile without modification.
  • FeedPoller can be exercised against a local python3 -m http.server: this gives fast, deterministic live QA for news-server --dry-run without relying on remote feeds or ntfy.
  • Replay and dry-run share the same formatter but not the same code path: DecisionRow + format_decision_row lives in news-server and is reused by news-cli replay; the replay harness stays isolated with an in-memory DB and a fixed --now.

Todo 18 — axum wiring, health/metrics, scheduler, retention pruning

  • Keep the binary thin: main.rs should only parse CLI args, load config, build the runtime, decide between one-shot and server mode, and start the Axum listener. Runtime construction, source conversion, default config, and ntfy setup belong in a bin_support module so both files stay under the 250 pure-LOC ceiling.
  • Move orchestration into main.rs if it keeps bin_support under LOC: run_once and start_background_loops live next to main() because they are the binary's specific flow; bin_support keeps reusable setup helpers.
  • Split oversized test modules into their own files: digest.rs was ~550 total LOC (mostly tests). Moving tests into digest/tests.rs and declaring #[cfg(test)] mod tests; in digest.rs drops the core module to ~170 LOC without changing test behavior. Avoid mod tests { ... } inside a file already named tests.rs — clippy flags it as module-inception.
  • Prometheus metrics need a stable label set from the start: Metrics registers news_poll_total, news_notify_total, news_suppress_total, and news_scrape_duration_seconds at startup with empty label values, then increments via typed helpers. This guarantees /metrics always emits the expected series even before the first poll.
  • Per-source scheduling must be per-source, not global: each enabled source gets its own tokio::time::interval task. A RateLimited result on one source only affects that source's next poll via a local backoff variable; other sources continue on their normal cadence.
  • Use spawn_blocking only for CPU-heavy work; DB writes stay async: the scheduler's poll pipeline (poll -> insert_if_new -> assign_or_create -> score -> gate -> notify) is fully async and runs in the source task. No blocking calls are introduced.
  • Pruning deletes orphan cluster_members first: the 90-day retention job deletes raw_items rows older than the cutoff, then removes any cluster_members whose item_id no longer exists. story_clusters, feedback, bayes_model, and percentile_stats are retained indefinitely.
  • duration_until_03_00 must handle the current-hour case: if the time is already past 03:00, target tomorrow at 03:00; if before, target today. A naive +1 day would delay the first pruning run by up to 24 hours.
  • Default config in the binary is helpful for local dev: when NEWS_CONFIG_PATH (default /config/config.toml) is missing, the server falls back to a built-in default with the three v1 sources. This matches the seed-source defaults and lets cargo run work outside the container.
  • Dry-run --once exits cleanly without real notifications: run_once polls every enabled source once, runs the digest path with a dummy ntfy publisher, and returns. It never publishes to the real ntfy topic and never starts the background Axum server.

Todo 19a — notification-gate peek + read-only HTTP API

  • Side-effect-free peek should share one private decision function with evaluate: extract the threshold/bypass/budget logic into a private decide() method that returns an internal DecisionOutcome. peek() clones the token bucket, refills it in memory, and calls decide() without writing notification_log or decrementing budget.tokens. This guarantees peek and evaluate never diverge.
  • PercentileTracker has no rank API: the frozen news-store crate exposes only record() and percentile(). To show percentile ranks in the UI, query percentile_stats directly with a window filter and compute the empirical CDF in application code.
  • pub(crate) is the least-invasive way to let sibling-module tests observe private state: NotificationGate.budget was made pub(crate) so api::stories::tests can assert that peek leaves budget.tokens unchanged. It does not appear in the crate's public API.
  • RFC3339 query parameters containing + must be URL-encoded or use Z format: to_rfc3339() emits +00:00, which query-string parsers treat as a space. In tests, use to_rfc3339_opts(SecondsFormat::Secs, true) to get the Z suffix and avoid encoding entirely.
  • Clippy 1.89 supports collapsing nested if let guards: the lint collapsible_if now suggests if let A = a && let B = b { ... }, which is stable in this toolchain. Prefer this over nested if let blocks.

Todo 19b — news-web Leptos 0.8 CSR UI

  • Mirror runway-web's Trunk + Tailwind v4 setup exactly: Trunk.toml uses a pre_build hook that invokes npx --prefix ../.. @tailwindcss/cli, index.html links styles/generated.css, and styles/main.css is a Tailwind v4 source file. This keeps the build self-contained and avoids Trunk's bundled Tailwind v3.
  • Keep the WASM bundle lean by cutting news-core: news-web does not need domain types from news-core; defining small DTOs locally (or reusing serde_json::Value) keeps the release .wasm under 600 KiB, leaving headroom for the todo 20 1.8 MiB budget.
  • Use relative /api/... URLs and let Trunk proxy in dev: gloo-net requests go to /api/stories, /api/feedback, and /api/config. In production the same-origin request reaches the server directly; in dev, Trunk.toml proxies /api to the backend. No absolute URLs are baked into the WASM.
  • On wasm32-unknown-unknown, use Action::new_local: Leptos 0.8's Action::new requires a Send future, which JS futures are not. Use Action::new_local for feedback actions backed by gloo_net.
  • Stories and Config views need matching loading and error states: a Suspense/Transition wrapper and a dedicated error panel give visible feedback when /api/stories or /api/config fail.
  • Playwright MCP expects Google Chrome at /opt/google/chrome/chrome: on Arch this required installing the system chromium package and symlinking /opt/google/chrome/chrome -> /usr/bin/chromium before the MCP could launch the browser.

Todo 20 — multi-stage Dockerfile, WASM bundle gate, NEWS_DATABASE_URL

  • Mirror runway/Dockerfile stage-for-stage, adapting only names and the Rust pin: web builds the frontend with Node copied from a pinned image and Trunk installed as a released binary; server builds the release binary inside a cache mount and copies it out; runtime is debian:bookworm-slim with only ca-certificates and tzdata. No toolchain remains in the final image.
  • Use the pinned toolchain in the Docker base image: rust-toolchain.toml says 1.89.0, so the Dockerfile uses rust:1.89-slim-bookworm, not a floating rust:slim-bookworm.
  • NEWS_DATABASE_URL must be parsed, not passed straight to sqlx: the task requires stripping the sqlite:// prefix to get the filesystem path, because the existing create-if-missing logic works on filesystem paths, not URLs. url.strip_prefix("sqlite://") gives /db/news.db; the URL is then rebuilt as sqlite:{path} for init_db.
  • A clap version flag is the cheapest container smoke test: adding #[command(version)] exposes --version/-V using the Cargo.toml version, avoiding the need to start the server or hit the database inside the image.
  • The CI bundle gate should run the exact same shell logic locally: the QA failure path rebuilt the frontend and ran find ... stat ... budget=1800000, proving the gate catches real overages and prints the byte overage message.
  • A 2 MB static array reliably inflates a WASM bundle for gate testing: it is also trivial to revert completely, which git diff confirmed.

Todo 21: deploy docs, update script, and systemd timer

  • Mirror runway's deploy README structure closely, but adapt the count. Runway has six setup steps (image, signing key, compose, Caddy, frontend dir, timer). news-triage has no signing key, but adds config-file creation, ntfy ACL provisioning, and Prometheus scraping. That makes eight steps. Call that out explicitly so the reader does not look for a missing seventh step.

  • The config-file example lives in crates/news-server/src/config.rs. There is no standalone config.example.toml in the repo; the working schema is the test fixture inside config.rs (lines 127-153). The README tells the operator to copy from there. If a separate example file is added later, the README path should be updated.

  • ntfy auth uses env vars, not a mounted secret file, matching every other containerized service in the estate. The publishing user needs rw access on the news-triage topic because auth-default-access is "deny-all". The exact command is in deploy/ntfy-access-snippet.md and repeated in the README.

  • Caddy validation against the scratch copy needs root on this host. caddy validate opens /var/log/caddy/access.log to set up the file writer, and /var/log/caddy is caddy:caddy drwxr-x---. The non-root command fails with a permission error that is unrelated to config syntax. caddy adapt succeeds without root and proves the file parses; sudo caddy validate succeeds and proves the full runtime config is valid. Record both in evidence.

  • podman-compose config passes cleanly against a scratch copy after pasting the compose service block and the Caddy volume mount. The scratch file must include the Caddy volume addition (./data/news/web:/srv/news:ro) as well as the new service, because the README documents both under step 4.

  • Atomic rename discipline is the same as runway: mount the holder (./data/news/web) at /srv/news, set root * /srv/news/dist, and extract the image's dist into the holder. A deploy renames dist to dist.previous and the staged directory to dist, so the old inode is preserved for rollback and Caddy sees the swap immediately.

  • The update script mirrors runway-update nearly line-for-line, with NEWS_* environment variables and news-backend defaults. The same three failure modes runway documents (rootless deploy, half-deploy, stale bind mount) apply here, so the comments were kept.

  • Protected-file proof is mandatory. The four estate files (~/compose.yml, ~/Caddyfile, ~/data/ntfy/etc/server.yml, ~/config/prometheus/prometheus.yml) must be stat-ted before and after. Access times may shift on reads, but Modify and Change timestamps must be unchanged. All four passed.

  • Do not auto-apply snippets. The README is operator-facing; the QA used scratch copies in /tmp/opencode. No real estate file was touched.

F2/F3 final-wave fixes

  • Trailing-window percentile ranks must live in one place: the original batch-local percentile_rank helper was duplicated across cycle.rs, api/stories.rs, and news-cli/src/replay.rs. Centralizing it in news-store::PercentileTracker::percentile_rank removes the duplication and guarantees the scheduler, API, and replay harness all rank against the same trailing-7-day distribution.
  • Use a stable empirical-CDF formula: (below * 100 + equal * 50) / count (implemented via f64::mul_add) avoids the small floating-point drift that (below + equal / 2.0) / count * 100.0 introduces for values like 5.5/10.
  • Model non-finite f64 honestly at the JSON boundary: the backend keeps f64::NEG_INFINITY as the blocklist-veto sentinel, but the API serializes it as null by making StoryRow.relevance an Option<f64> mapped with is_finite().then_some(...). The frontend mirrors the option and renders so the entire story list no longer fails when a blocklisted item is present.
  • Host-target tests for the frontend DTO are viable: news-web can host-run small serde_json::from_str regression tests because they only exercise the serde-derived DTOs and do not depend on gloo-net or Leptos runtime APIs.