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.tomlpinned tochannel = "1.89.0"(exactrustc --versionon this box), withcomponents = ["rustfmt", "clippy"]andtargets = ["wasm32-unknown-unknown"]. Firstcargo buildauto-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.rsfiles (doc comments only, no code usingserde/thiserror/news-core),cargo machetecorrectly flags every declared dep as unused and exits 1. This is EXPECTED, not a bug — it will self-resolve once todo 2 addsserde-derived types tonews-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/— nocargo install --lockedneeded locally (CI still installs them fresh per the workflow, matching runway's pattern). cargo deny checkemitslicense-not-encounteredwarnings (not failures) for licenses listed indeny.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 passescargo check -p news-web --target wasm32-unknown-unknownsince it has zero non-wasm-safe dependencies — so the CIcheckjob's wasm step already works even before Leptos is wired in (Wave 5 / todo 19). - Workspace-level
[workspace.dependencies]kept toserde(derive feature)thiserrorper the todo's explicit "must not" constraint — every crate's ownCargo.tomlhas[lints] workspace = trueto inherit the deny/forbid lint set, mirroring runway's per-crate convention exactly.
Todo 2 — news-core domain types
unwrap_used/expect_useddeny is workspace-wide, including tests — confirmed against runway's convention: runway does NOT scope the lint exception to#[cfg(test)] mod testsblocks; it uses#![allow(clippy::unwrap_used, clippy::expect_used)]at the top of standalonetests/*.rsintegration test files. For inline#[cfg(test)] mod testsinsidesrc/model.rs(this todo's case, since the plan asked for the test inmodel.rsitself), the cleanest option that needs NO#[allow]at all is: write#[test]functions that returnResult<(), serde_json::Error>and use the?operator on the fallibleserde_json::to_string/from_strcalls, thenassert_eq!for the actual behavioral check (which never panics via unwrap/expect — it usesassert_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 aResult-returning test (e.g. asserting a panic via#[should_panic], or needingUuid::new_v4()fallibility that never actually fails).uuid/chronoworkspace 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 addedserde_jsonandpretty_assertionsto[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-corenow has realserde-derived types, soserdeis 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 aDELETE FROM raw_items_fts. The first column value must literally be the string'delete'and the second must be therowidto 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) alongsideold.rowid— omitting the column values or passingnew.*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_ftsstatements 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 onsqlite::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 againstsqlite::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.tomlpin of1.89.0— see decisions.md for the MSRV details.cargo testfails 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 plainquick_xml::reader::Reader(notNsReader) and match element names as&strviaQName/LocalName'sAsRef<str>(quick-xml 0.42'sQNamewraps&str, not&[u8]— the API changed since older quick-xml versions that some examples online still show as byte-stringb"tag"matching). For the AP news-sitemap,news:titleandnews:publication_dateare 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 exactlynews:— 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_intowould 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 overNsReaderbecause 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::Opinionis assigned inrss_atom.rs'sitem_kind_from_category()when the RSS<category>text (case-folded) contains the substring"opinion"or"comment"; everything else (including a missing<category>) defaults toItemKind::News. Atom<category term="...">values feed the same heuristic via thetermattribute. The news-sitemap dialect has no per-item category field at all, soparse_news_sitemap()always emitsItemKind::News— AP items can never be classifiedOpinionby 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 treatItemKind::Opinionas a soft/approximate signal, not authoritative. quick_xml0.42 API notes (differs from older docs found online):BytesTexthas no.unescape()method in this version — usequick_xml::escape::unescape(text.into_inner().as_ref())directly.Attribute::unescape_value()is deprecated in favor ofAttribute::normalized_value(XmlVersion::Explicit1_0).AttributesiteratesResult<Attribute, AttrError>, andAttrErrorconverts toquick_xml::ErrorviaFrombut not directly to a custom error type, so call sites need.map_err(quick_xml::Error::from)?before the?propagates into athiserror-derivedIngestErrorthat only has#[from] quick_xml::Error.- Retry-After: only the integer-seconds form is parsed
(
parse_retry_afterinlib.rs); the HTTP-date form (Retry-After: Wed, 21 Oct 2026 07:28:00 GMT) is intentionally left unparsed (falls back toNone) per the plan's explicit allowance to "document if you skip HTTP-date parsing" — todo 18's scheduler should not assumeRateLimited(None)means "no rate limit signal was sent", since it also covers this unparsed-date case. - Live smoke test result:
curl -sIagainsthttps://www.aljazeera.com/xml/rss/all.xmlwith the exact honest UA returnedHTTP/2 200withetag/last-modifiedresponse headers present — network access was available in this sandbox, so this is a real (not skipped) verification.
Todo 8 — ItemRepo + FTS5 BM25 search
- Unique-violation detection: the clean dedupe path is
matchon thesqlx::query(...).execute(...).awaitresult and mapErr(sqlx::Error::Database(db_err)) if db_err.is_unique_violation()toInsertOutcome::AlreadyExists. Do not pre-SELECT — it is race-prone and slower, and the SQLiteUNIQUE(source_id, link)constraint is the authoritative guard. - FTS5 auto-sync: never insert/update
raw_items_ftsdirectly. The external-content triggers in0001_init.sql(raw_items_ai,raw_items_ad,raw_items_au) mirrorraw_itemsinto the FTS5 table automatically. Manual inserts would duplicate entries and corrupt ranking. - MATCH with a bound parameter is safe:
WHERE raw_items_fts MATCH ?1withsqlx::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 to0).ORDER BY ranktherefore 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_ftsis declared withcontent = 'raw_items'andcontent_rowid = 'rowid', the FTS5rowidis the same asraw_items.rowid. Join isJOIN raw_items r ON r.rowid = f.rowid;SELECT r.*then maps cleanly to the domainRawItem. chronomust be declared innews-store/Cargo.tomleven though theRawItemtype lives innews-core. Serialization to RFC3339 strings (published_at.to_rfc3339()) and parsing back (DateTime::parse_from_rfc3339) happen insidenews-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" is5/7 ≈ 0.714, well above0.5; a looser title variant like "Israel and Hamas reach ceasefire agreement" scored only3/7 ≈ 0.429and therefore started its own cluster.0.5is 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 usessc.last_seen_at <= ?1(the new item'spublished_atas RFC3339) andjulianday(?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.0ensures a 46-minute gap is excluded; a<=would incorrectly merge across the boundary. source_countmust beCOUNT(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 yieldsource_count == 1until a different source joins.- All assignment writes belong in one sqlx transaction (
pool.begin(), updates,tx.commit()). This prevents a crash between inserting acluster_membersrow and updatingstory_clusters.source_count. - Clippy
manual_contains: for a&[&str]slice, preferSTOPWORDS.contains(&token)overiter().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 at0.0. This keeps the BM25 term non-negative while preserving the ordering fromItemRepo::search_fts. - Tokenizer sharing:
news_core::clustering::tokenizeis now public and returnsVec<String>using the same rules as title-similarity clustering.similarityconverts the vector to aHashSetso its behavior is unchanged; the scorer keeps duplicates so repeated words contribute multiple times to the Bayes log-odds sum. - Model persistence: the
bayes_modeltable's singleton row (id = 1) is read onRelevanceScorer::loadand written withINSERT OR REPLACEafter everytrain.serde_jsonround-trips the model becauseNaiveBayesModelnow derivesSerializeandDeserialize. - Do not implement
news_core::scoring::relevance::Bm25Sourcehere. The trait is synchronous, butnews-store/sqlxaccess is async. The scorer takes its BM25 signal directly fromItemRepo::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:
ItemRepokeeps itsSqlitePoolfield private, soRelevanceScorerretains its ownpool: SqlitePoolclone for the singleton-row read/write inloadandtrain. - Blocklist veto is first and unconditional: case-insensitive substring
match against
title + " " + summaryreturnsf64::NEG_INFINITYbefore BM25 or Bayes are computed. Training the model heavily on the item's own tokens asInteresteddoes not override the veto. - Test naming: placing
#[tokio::test]functions at module level (with#[cfg(test)]on each helper and test) yields paths likescoring::blocklist_veto_returns_neg_infinity, matching the expected test filter exactly.
Task 11 — PercentileTracker + ImportanceScorer
- Keep distributions strictly separate:
percentile_stats.metric_kindstores"importance"and"relevance"as TEXT discriminants, and every query filters onmetric_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()(noto_rfc3339_opts). SQLite TEXT comparison then orders ISO timestamps correctly, so thecomputed_at >= cutofffilter is deterministic and precision-matched on both sides. - Window edge case: rows with
computed_atolder thanwindow_daysare excluded by the query, not by post-filtering. Inserting a row directly via SQL with an 8-day-old timestamp and querying withwindow_days = 7proves the filter excludes it. - Empty window must return
StoreError::InsufficientData, not a default0.0and not a panic. TheInsufficientDatavariant already existed withneeded/availablefields; the tracker uses{ needed: 1, available: 0 }. - Percentile indexing: with
countsorted values, the value at indexceil(p / 100.0 * count) - 1(clamped tocount - 1) gives the expected exact results: 100 values1.0..=100.0at p90 returns90.0, and p100 returns the maximum. - ImportanceScorer trust term: use the maximum
Source.weightamong the cluster's distinct member sources. The cluster may contain multiple items from the same source;COUNT(DISTINCT raw_items.source_id)is alreadycluster.source_count, andMAX(sources.weight)captures the strongest source in the corroboration set. v1 hard-codes prominence to0because 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
StoreErrorconversion:sqlx::Errormust be mapped with.map_err(news_store::StoreError::from)?becauseServerErroronly implementsFrom<StoreError>, notFrom<sqlx::Error>. - Age parameter: the importance formula accepts
corroboration_window_minutesfor API completeness but does not use it numerically in v1.score_clusterstill 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:
NotificationGatemust holdSqlitePoolbecause everyevaluate()call reads from and writes tonotification_log(already-notified check, bypass ceiling count, and success logging). The struct isNotificationGate { pool, budget, config }. - Bypass lane is fully independent of the normal token bucket: bypass
decisions neither consume nor refill
TokenBucket.tokens; the acceptance testbypass_ceiling_is_independent_of_normal_budgetproves this by pre-exhausting the bucket to0.0and still allowing 4 bypassNotifydecisions before the 5th falls through toSuppress(BudgetExhausted). - In-memory token bucket is an accepted v1 limitation:
TokenBucketstorestokensandlast_refillin memory, so a process restart resets the budget. Persistence is intentionally out of scope for todo 12. - Corroboration bypass inference:
cluster.source_count >= 2qualifies a story for the bypass lane becauseStoryClustermembers 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 like95.0and80.0directly; 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 < 7onnow.with_timezone(&America::Louisville). Relevance in the digest band (75 <= r < 90) still producesDigesteven during quiet hours; higher relevance during quiet hours producesSuppress(QuietHours). - Already-notified check runs before bypass: the latest
notification_logrow for the cluster is fetched first. Ifcluster.source_count < source_count_at_notify + material_update_source_delta, the evaluation returnsSuppress(AlreadyNotified)before any bypass or normal-path logic. - RFC 3339 TEXT for DB reads/writes and cutoff comparison:
created_atis stored withnow_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 aLocalResult, so tests use.single().ok_or_else(|| ServerError::DateTime(...))?. This required adding aServerError::DateTime(String)variant. - Clippy
collapsible_ifonlet/ifchain: the already-notified check was cleaner asif let Some(...) = ...? && cluster.source_count < .... - Digest is a decision only:
GateDecision::Digestdoes not persist digest content or queue entries; todo 16 owns digest content selection.
Todo 13 — POST /api/feedback + FeedbackRepo + incremental Naive Bayes retraining
FeedbackRepomirrorssources.rs/items.rs:pub struct FeedbackRepo(SqlitePool)withnew(pool),insert, and query helpers.kind_to_strmapsFeedbackKindto snake_case TEXT exactly likeItemKind/SourceKind.canonical_item_textusesfetch_optional: returnsNonewhen the cluster is missing, letting the handler return the same 400 as an unknown cluster id.Json<FeedbackRequest>gives 422 for free:FeedbackKindalready has#[serde(rename_all = "snake_case")]; an unknown variant like"bogus"fails deserialization and axum returns 422JsonDataErrorwith no custom parsing code.RelevanceScorer::load(..., Vec::new(), Vec::new())is fine for training:load()reads the persistedbayes_modelrow;train()only increments counts and writes the row back. Empty interests/blocklist are irrelevant becausetrain()never consults them.- Incremental training only: each request calls
scorer.train(&tokens, kind)once. The model is never rebuilt from thefeedbacktable. tower::ServiceExt::oneshotfor in-process tests: build theRouter, thenapp.oneshot(Request::builder()...body(...)?)with no TCP listener. In axum 0.8oneshotreturnsResult<Response, Infallible>; handle theInfalliblewith an exhaustivematchto satisfy theunwrap_used/expect_useddeny.- Map HTTP/builder errors into
ServerErrorin tests:Request::builder().body()andaxum::body::to_bytes()can fail; map them toServerError::DateTime(or propagate via?) so tests returnResult<(), ServerError>without unwrap/expect. - Keep axum/tower imports tidy:
BodyandRequestare test-only, so they live inside#[cfg(test)] mod tests; otherwisedead_code/unused_importswarnings fire and-D warningsfails.
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 keysurl,kind,weight,enabled; onlypoll_interval_secsis 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_endas"HH:MM"strings;NotifyConfighasquiet_start_hour/quiet_end_hourasu32. A parser will be needed to translate"22:00"→22and"07:00"→7. - Config has
budget_refill_per_day/budget_burst(f64) andbypass_ceiling_per_day(u32);NotifyConfiglacks budget fields (they feedTokenBucket::new) and calls the bypass limitbypass_daily_ceiling: u32. NotifyConfigcarries 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.
- Config has
- All-or-nothing reload:
reload_onceparses the file outside the lock, then acquires the write lock and replaces the value. A parse error returnsErrwithout touching the in-memory config; the SIGHUP watcher logs the error and continues. tracing_test::traced_test+logs_containcaptures real logs in async tests.reload_onceitself returnsErr; the test logs that error withtracing::error!and assertslogs_contain("toml parse error"), mirroring whatwatch_sighupdoes in production.- No unwrap/expect in tests or production:
tempfile::tempdir()?,std::fs::write(&path, ...)?, andload(&path)?all leverage the newServerError::Io/ServerError::Toml#[from]conversions. The SIGHUP handler installation failure is handled withmatchrather than.expect()because the workspace deniesexpect_used. - Cargo workspace inheritance with additive features: promoting
tokioto a regular dependency innews-server/Cargo.tomlusestokio = { 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
Actionsheader: 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 andInvalidHeaderValueerrors. - Retry-once logic should be explicit, not a loop: one
matchfor the first attempt, a 500 mstokio::time::sleep, then a second attempt. This guarantees exactly one retry and no infinite loops. NEWS_API_BASE_URLdefault belongs in the publisher: the ntfy notification needs a concrete API base URL for itshttpfeedback actions.from_envreadsNEWS_API_BASE_URLand falls back tohttp://localhost:3000.- Wiremock
Mock::expect(n)verifies call counts on guard drop: hold the returnedMockGuarduntil after the assertion so the expectation is not unregistered early.expect(2)plusrespond_with(500)cleanly asserts the retry-test "exactly 2 requests" contract. #[allow(dead_code)]onNtfyPublisheris acceptable when the spec requires stored fields that have no current reader: the struct must keepbase_urlandtopicper the todo, even though onlytopic_urlis 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 percentilep, not the percentile rank of a value: the task's pseudo-callpercentile(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 testp75 <= score && score < p90, which is equivalent to checking that the score's rank falls in[75, 90).- DateTime with a generic
TimeZonedoes not implementDisplay: theTz: TimeZonegeneric used inbuild_and_send_digestpreventstracing::debug!with{}. Use structured debug fields (tracing::debug!(?now, "...")) or convert toDateTime<Utc>before formatting. - Digest idempotency relies on
notification_log.lane = 'digest': a single row with a nil UUID cluster ID andsource_count_at_notifyset to the number of stories is sufficient; no token-bucket or cluster-specific log rows are needed. NtfyPublisherretry logic can be factored without changing existing tests: extractingsend_with_retryletspublish_digestshare the one-retry behavior ofpublishwhile keeping the original tests passing unchanged.RawItem.kind/raw_items.kindalready distinguishes opinion pieces: no schema change was required to honornotify_opinions; the digest joins through to the canonicalraw_itemsrow and filters onItemKind::Opinion.
Todo 17 — replay harness and server dry-run
SqlitePool::connectin this sqlx/sqlite build does not create the database file: the defaultNEWS_DB_PATH=./news.dbmust be pre-created as an empty file (and its parent directories) beforeinit_dbopens it; in-memory pools (sqlite::memory:) are unaffected.- Dry-run suppression isolation is enforced in SQL, not Rust: every
suppression-relevant read of
notification_logmust includeAND 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_modeandbuild_and_send_digest_with_modelets the new code usedry_runwhile all existing tests and call sites compile without modification. FeedPollercan be exercised against a localpython3 -m http.server: this gives fast, deterministic live QA fornews-server --dry-runwithout relying on remote feeds or ntfy.- Replay and dry-run share the same formatter but not the same code path:
DecisionRow+format_decision_rowlives innews-serverand is reused bynews-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.rsshould 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 abin_supportmodule so both files stay under the 250 pure-LOC ceiling. - Move orchestration into
main.rsif it keepsbin_supportunder LOC:run_onceandstart_background_loopslive next tomain()because they are the binary's specific flow;bin_supportkeeps reusable setup helpers. - Split oversized test modules into their own files:
digest.rswas ~550 total LOC (mostly tests). Moving tests intodigest/tests.rsand declaring#[cfg(test)] mod tests;indigest.rsdrops the core module to ~170 LOC without changing test behavior. Avoidmod tests { ... }inside a file already namedtests.rs— clippy flags it as module-inception. - Prometheus metrics need a stable label set from the start:
Metricsregistersnews_poll_total,news_notify_total,news_suppress_total, andnews_scrape_duration_secondsat startup with empty label values, then increments via typed helpers. This guarantees/metricsalways 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::intervaltask. ARateLimitedresult on one source only affects that source's next poll via a local backoff variable; other sources continue on their normal cadence. - Use
spawn_blockingonly 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_membersfirst: the 90-day retention job deletesraw_itemsrows older than the cutoff, then removes anycluster_memberswhoseitem_idno longer exists.story_clusters,feedback,bayes_model, andpercentile_statsare retained indefinitely. duration_until_03_00must 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 daywould 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 letscargo runwork outside the container. - Dry-run --once exits cleanly without real notifications:
run_oncepolls 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 internalDecisionOutcome.peek()clones the token bucket, refills it in memory, and callsdecide()without writingnotification_logor decrementingbudget.tokens. This guaranteespeekandevaluatenever diverge. PercentileTrackerhas no rank API: the frozennews-storecrate exposes onlyrecord()andpercentile(). To show percentile ranks in the UI, querypercentile_statsdirectly 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.budgetwas madepub(crate)soapi::stories::testscan assert thatpeekleavesbudget.tokensunchanged. It does not appear in the crate's public API.- RFC3339 query parameters containing
+must be URL-encoded or useZformat:to_rfc3339()emits+00:00, which query-string parsers treat as a space. In tests, useto_rfc3339_opts(SecondsFormat::Secs, true)to get theZsuffix and avoid encoding entirely. - Clippy 1.89 supports collapsing nested
if letguards: the lintcollapsible_ifnow suggestsif let A = a && let B = b { ... }, which is stable in this toolchain. Prefer this over nestedif letblocks.
Todo 19b — news-web Leptos 0.8 CSR UI
- Mirror
runway-web's Trunk + Tailwind v4 setup exactly:Trunk.tomluses apre_buildhook that invokesnpx --prefix ../.. @tailwindcss/cli,index.htmllinksstyles/generated.css, andstyles/main.cssis 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-webdoes not need domain types fromnews-core; defining small DTOs locally (or reusingserde_json::Value) keeps the release.wasmunder 600 KiB, leaving headroom for the todo 20 1.8 MiB budget. - Use relative
/api/...URLs and let Trunk proxy in dev:gloo-netrequests go to/api/stories,/api/feedback, and/api/config. In production the same-origin request reaches the server directly; in dev,Trunk.tomlproxies/apito the backend. No absolute URLs are baked into the WASM. - On
wasm32-unknown-unknown, useAction::new_local: Leptos 0.8'sAction::newrequires aSendfuture, which JS futures are not. UseAction::new_localfor feedback actions backed bygloo_net. - Stories and Config views need matching loading and error states: a
Suspense/Transitionwrapper and a dedicated error panel give visible feedback when/api/storiesor/api/configfail. - Playwright MCP expects Google Chrome at
/opt/google/chrome/chrome: on Arch this required installing the systemchromiumpackage and symlinking/opt/google/chrome/chrome -> /usr/bin/chromiumbefore the MCP could launch the browser.
Todo 20 — multi-stage Dockerfile, WASM bundle gate, NEWS_DATABASE_URL
- Mirror
runway/Dockerfilestage-for-stage, adapting only names and the Rust pin:webbuilds the frontend with Node copied from a pinned image and Trunk installed as a released binary;serverbuilds the release binary inside a cache mount and copies it out;runtimeisdebian:bookworm-slimwith onlyca-certificatesandtzdata. No toolchain remains in the final image. - Use the pinned toolchain in the Docker base image:
rust-toolchain.tomlsays1.89.0, so the Dockerfile usesrust:1.89-slim-bookworm, not a floatingrust:slim-bookworm. NEWS_DATABASE_URLmust be parsed, not passed straight to sqlx: the task requires stripping thesqlite://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 assqlite:{path}forinit_db.- A clap
versionflag is the cheapest container smoke test: adding#[command(version)]exposes--version/-Vusing 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 diffconfirmed.
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 standaloneconfig.example.tomlin the repo; the working schema is the test fixture insideconfig.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
rwaccess on thenews-triagetopic becauseauth-default-accessis"deny-all". The exact command is indeploy/ntfy-access-snippet.mdand repeated in the README. -
Caddy validation against the scratch copy needs root on this host.
caddy validateopens/var/log/caddy/access.logto set up the file writer, and/var/log/caddyiscaddy:caddydrwxr-x---. The non-root command fails with a permission error that is unrelated to config syntax.caddy adaptsucceeds without root and proves the file parses;sudo caddy validatesucceeds 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, setroot * /srv/news/dist, and extract the image'sdistinto the holder. A deploy renamesdisttodist.previousand the staged directory todist, 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 andnews-backenddefaults. 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_rankhelper was duplicated acrosscycle.rs,api/stories.rs, andnews-cli/src/replay.rs. Centralizing it innews-store::PercentileTracker::percentile_rankremoves 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 viaf64::mul_add) avoids the small floating-point drift that(below + equal / 2.0) / count * 100.0introduces for values like 5.5/10. - Model non-finite f64 honestly at the JSON boundary: the backend keeps
f64::NEG_INFINITYas the blocklist-veto sentinel, but the API serializes it asnullby makingStoryRow.relevanceanOption<f64>mapped withis_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-webcan host-run smallserde_json::from_strregression tests because they only exercise theserde-derived DTOs and do not depend ongloo-netor Leptos runtime APIs.