58 KiB
news-triage - Work Plan
TL;DR (For humans)
What you'll get: A small self-hosted news service that quietly watches a handful of world-news feeds, figures out on its own which stories actually matter to you, and pings your phone through your existing notifications app - roughly twice a day on average, with an automatic exception for genuinely big breaking news. A tiny web page lets you see what it noticed, why it did or didn't notify you, and give it thumbs-up/thumbs-down feedback it learns from over time.
Why this approach: No AI model is involved anywhere - all the "which story matters" judgment is plain arithmetic and statistics (the same math spam filters have used for decades), which is faster, cheaper, and never randomly changes its mind. It's built the same solid way as your other self-hosted apps: one real packaged service, tested as it's built, not a script that might quietly stop working.
What it will NOT do: It won't read full articles or republish their text - just headlines and links. It won't touch the shared LLM/GPU setup at all. It won't be reachable from the public internet, and it won't ever exceed its notification budget except through a separately-capped "this is actually big" lane.
Effort: Large Risk: Medium - the biggest risk is the notification-tuning math (what counts as "interesting enough") needing real-world adjustment after it's live; the replay/dry-run tooling built into the plan exists specifically to let that be tuned safely. Decisions to sanity-check: (1) it starts with only 3 news sources (Al Jazeera, BBC, AP) since that's all that's confirmed to work for free; (2) it's LAN/VPN-only with no public web address, same as your other internal dashboards; (3) config changes are made by editing one file, not clicking through the web UI, at least for this first version.
Your next move: run $start-work news-triage to begin execution, or ask for a high-accuracy review first. Full execution detail follows below.
TL;DR (machine): Large effort, Medium risk - Rust workspace + containerized service (6 crates, non-LLM IR-based news triage, Leptos web UI, ntfy notifications), 21 implementation todos across 5 waves + 4 final-verification reviewers.
Scope
Must have
- Cargo workspace (
news-core,news-ingest,news-store,news-server,news-web,news-cli) under/home/connor/docs/projects/news/crates/, Rust edition 2024,rust-toolchain.tomlpinned. - Source registry with Al Jazeera RSS, BBC World RSS, AP news-sitemap enabled; Reuters config-ready but disabled. Per-source weight/enable/poll-interval (honoring declared feed TTL, 5-min floor), conditional GET (ETag/If-Modified-Since), honest User-Agent, backoff on 429/5xx.
- Story normalization, dedupe, clustering, cross-source corroboration counting scaled to the enabled source count.
- Two-axis non-LLM scoring: importance (arithmetic: prominence + source trust + corroboration) and relevance (SQLite FTS5
bm25()+ Naive Bayes feedback model with a defined cold-start fallback), each tracked as its own trailing-7-day percentile distribution. - Notification gate: continuously-refilling token bucket, quiet hours, adaptive percentile threshold (90th percentile relevance), breaking-news bypass lane (99th percentile importance OR corroboration >= 2-of-N sources within 45 min) capped at its own independent 24h ceiling, duplicate/story-cluster suppression.
- ntfy publishing (title, markdown body, priority, click-through link, feedback action buttons) plus a documented
ntfy accessgrant command for the operator. - Feedback capture from action buttons, persisted, feeding the Naive Bayes model.
- C7 daily digest (07:00 America/Louisville, low priority, outside the notification budget) for stories between the 75th and 90th relevance percentile.
- C8 replay harness (
news-cli replay <fixtures>) and a--dry-runserver flag; both print decisions without calling ntfy. - Operator control surface:
./config/news/config.toml(sources, notify settings, topics/blocklist), hot-reloaded on SIGHUP, plus a tiny Leptos web UI for reviewing stories, suppression reasons, and feedback. - Containerized deployment: one image (frontend
distextracted to a host directory at deploy time, never served by the backend),/healthz,/metrics, data-retention pruning job (raw items > 90 days), failure notifications. - CI:
fmt --check,clippy -D warnings,cargo test --workspace,cargo machete,cargo deny check, WASM bundle-size budget (1.8MB). - Operator-applied snippets (not applied by the implementer) for:
~/compose.ymlservice block,~/CaddyfileLAN-only vhost, ntfy ACL grant,~/config/prometheus/prometheus.ymlscrape config. - TDD throughout: every scoring/gate/clustering unit lands behind a failing-first test.
Must NOT have (guardrails, anti-slop, scope boundaries)
- No LLM, LiteLLM, llama.cpp, or GPU dependency anywhere in the pipeline.
- No full-article scraping, paywall circumvention, or republishing article text; headline + feed summary + link only.
- No paid API, no hosted AI service, no new Postgres/Redis/Kafka/Kubernetes.
- No public consumer-facing product, mobile app, new public DNS record, or Authelia gate — LAN/VPN-only posture, matching grafana/llama-swap.
- No implementer edits to
~/compose.yml,~/Caddyfile,~/data/ntfy/etc/server.yml, or~/config/prometheus/prometheus.yml— snippets only. - No unbounded breaking-news notifications — the bypass lane is capped independently of the normal budget.
- No serving the frontend SPA from the backend process — the image only lifts
distout for Caddy. - No invented MVP/phase-1 reduction of any of the above.
Verification strategy
Zero human intervention - all verification is agent-executed.
- Test decision: TDD (user-specified). Framework:
cargo test(workspace), Rust's built-in test harness +pretty_assertionsfor unit/integration tests innews-core/news-ingest/news-store/news-server;wasm-bindgen-testor Trunk-driven browser check fornews-webwhere a DOM assertion is needed;sqlite3CLI andcurlfor manual/agent-executed QA against the running container. - Evidence:
.omo/evidence/+<attemptDir>/task-<N>-news-triage.<ext>(attemptDir = currentAttemptDir fromomo ulw-loop status --json,.omo/evidence/ulw/<session>/<goalId>/a<attempt>; outside ulw-loop use.omo/evidence/).
Execution strategy
Parallel execution waves
Target 5-8 todos per wave. Fewer than 3 (except the final) means you under-split.
- Wave 1 - Workspace scaffold & core domain (todos 1-4): Cargo workspace skeleton, lints, CI,
news-coredomain types + scoring math (pure, TDD). - Wave 2 - Ingest & storage (todos 5-9): SQLite schema/migrations, FTS5 setup, source registry, feed polling + conditional GET, story clustering/corroboration.
- Wave 3 - Scoring, gate & feedback (todos 10-14): relevance scorer (BM25+Bayes+cold-start), importance scorer, percentile-threshold tracker, notification gate (budget/quiet-hours/bypass/suppression), feedback capture.
- Wave 4 - Notify, digest, replay & server (todos 15-18): ntfy publisher, daily digest (C7), replay/dry-run harness (C8), axum server wiring (
/healthz,/metrics, scheduler, config hot-reload). - Wave 5 - Frontend & container (todos 19-21): Leptos web UI, Dockerfile (one image, dist-lift build), operator snippets (compose/Caddy/ntfy-ACL/Prometheus).
- Final wave - verification (F1-F4): plan compliance, code quality, real manual QA, scope fidelity.
Dependency matrix
| Todo | Depends on | Blocks | Can parallelize with |
|---|---|---|---|
| 1 | - | 2,3,4 | - |
| 2 | 1 | 5-21 | 3,4 |
| 3 | 1 | 21 | 2,4 |
| 4 | 2 | all news-core consumers |
3 |
| 5 | 2 | 6,7,8,9 | - |
| 6 | 5 | 8 | 7 |
| 7 | 5 | 8 | 6 |
| 8 | 6,7 | 9,10 | - |
| 9 | 8 | 11 | 10 |
| 10 | 4,8 | 12,13 | 9 |
| 11 | 9,10 | 12 | - |
| 12 | 10,11 | 13,14 | - |
| 13 | 12 | 15 | 14 |
| 14 | 12 | 16 | 13 |
| 15 | 13 | 17,20 | - |
| 16 | 13,14 | 17 | 15 |
| 17 | 15,16 | 18,19 | - |
| 18 | 4,8 | 19 | 17 |
| 19 | 17,18 | 21 | - |
| 20 | 15 | 21 | 19 |
| 21 | 3,19,20 | F1-F4 | - |
| F1-F4 | 21 | - | each other |
Todos
Implementation + Test = ONE todo. Never separate.
-
1. Scaffold Cargo workspace, lints, rust-toolchain.toml, and CI skeleton What to do: Create
/home/connor/docs/projects/news/Cargo.tomlas a workspace withresolver = "3",members = ["crates/news-core","crates/news-ingest","crates/news-store","crates/news-server","crates/news-web","crates/news-cli"],[workspace.package](version 0.1.0, edition "2024", license "MIT"),[workspace.lints.rust](dead_code="deny", unused_must_use="deny", unsafe_code="forbid"),[workspace.lints.clippy](unwrap_used="deny", expect_used="deny"). Create each crate directory with a minimalCargo.toml+src/lib.rs(ormain.rsfor news-server/news-cli) that compiles. Addrust-toolchain.tomlat repo root pinned to the stable channel (rustc --versionon this box to pick the exact version) withtargets = ["wasm32-unknown-unknown"]. Create.gitea/workflows/ci.ymlwith jobscheck(fmt --check, clippy -D warnings, cargo test --workspace, wasm check for news-web),guardrails(cargo-machete, cargo-deny),bundle(WASM size budget placeholder, wired fully in todo 20). Must NOT do: do not add any dependency to[workspace.dependencies]beyond what this todo's skeleton needs (serde, thiserror for now) — later todos add their own. Parallelization: Wave 1 | Blocked by: none | Blocks: 2,3,4 References (executor has NO interview context - be exhaustive):/home/connor/docs/projects/runway/Cargo.toml:1-26(workspace shape, lints, edition),/home/connor/docs/projects/runway/.gitea/workflows/ci.yml:1-121(job structure fmt/clippy/test/wasm-check/machete/deny/bundle),.omo/drafts/news-triage.mdlines 27 (crate list + responsibilities), 32-33 (CI/toolchain decisions). Acceptance criteria (agent-executable):cd /home/connor/docs/projects/news && cargo build --workspaceexits 0;cargo fmt --all --checkexits 0;cargo clippy --workspace --all-targets -- -D warningsexits 0. QA scenarios: happy -cargo metadata --format-version=1 | jq '.workspace_members'lists exactly 6 crate ids, evidence.omo/evidence/task-1-news-triage.txt; failure - remove a member temporarily and confirmcargo build --workspacefails with "package not found", then restore, evidence same file. Commit: Y | chore(workspace): scaffold Cargo workspace, lints, toolchain pin, CI skeleton -
2. news-core: domain types (Story, Source, Cluster, ScoreBreakdown, Feedback) What to do: In
crates/news-core/src/model.rs, define pureserde-derived structs/enums with no I/O:Source { id, name, url, kind: SourceKind (RssAtom|NewsSitemap), weight: f64, enabled: bool, poll_interval_secs: u32 },RawItem { id, source_id, title, summary, link, published_at, kind: ItemKind (News|Opinion) },StoryCluster { id, canonical_item_id, member_item_ids: Vec<Uuid>, source_count: u32, first_seen_at, last_seen_at },ScoreBreakdown { importance: f64, relevance: f64, importance_percentile: f64, relevance_percentile: f64 },Feedback { id, story_cluster_id, kind: FeedbackKind (Interested|NotInterested), created_at }. Addmod model;tocrates/news-core/src/lib.rsand re-export. Must NOT do: no database types (nosqlx::FromRowhere — that lives in news-store's own DTOs that convert to/from these); no network types. Parallelization: Wave 1 | Blocked by: 1 | Blocks: 5,6,7,8,9 (all downstream crates consume these types) References:.omo/drafts/news-triage.mdlines 15-22 (Components C1-C8, defines Story/Cluster/Score/Feedback concepts), line 43 (Sources v1), line 46 (op-ed flagkind=opinion). Acceptance criteria:cargo test -p news-coreexits 0 with at least one test assertingSource,RawItem,StoryCluster,ScoreBreakdown,Feedbackround-trip throughserde_json::to_string/from_strunchanged (property:assert_eq!(deserialized, original)). QA scenarios: happy -cargo test -p news-core model::tests::round_trippasses, evidence.omo/evidence/task-2-news-triage.txt(paste test output); failure - construct aRawItemwithkind: ItemKind::Opinionand assert it serializes the discriminant as"opinion"(locks the wire format other components match on), evidence same file. Commit: Y | feat(news-core): add domain model types -
3. news-core: importance scoring function (prominence + trust + corroboration) What to do: In
crates/news-core/src/scoring/importance.rs, implementpub fn importance_score(prominence_rank: u32, source_trust_weight: f64, corroboration_count: u32, corroboration_window_minutes: u32) -> f64combining: prominence (inverse of feed position, e.g.1.0 / (1.0 + prominence_rank as f64)), source trust (theSource.weightfrom todo 2), and corroboration (monotonic increasing incorroboration_count, saturating so a 10th corroborating source doesn't dominate a 3rd). Write the exact formula as a doc comment with a worked numeric example. TDD: write failing tests FIRST for each of these properties, then implement: (a) more corroboration never decreases the score, (b) higher source trust never decreases the score, (c) a single-source story with rank 0 scores lower than a 3-source-corroborated story with rank 0, (d) score is deterministic (same inputs -> same output, bit-for-bit) across two calls. Must NOT do: no floating-point nondeterminism (noHashMapiteration order affecting the result; no time-of-day dependence inside this pure function —corroboration_window_minutesis a parameter, not read from the system clock here). Parallelization: Wave 1 | Blocked by: 2 | Blocks: 10 (importance scorer wiring), 11 References:.omo/drafts/news-triage.mdline 69 (D3: "Importance is arithmetic, not learned"), lines 15-16 (C2-cluster, C3-score), line 41 (breaking bypass corroboration/importance percentile). Acceptance criteria:cargo test -p news-core scoring::importanceexits 0, showing at least the 4 properties above as separate#[test]functions, each with an assertion, none behind#[ignore]. QA scenarios: happy -cargo test -p news-core importance_more_corroboration_never_decreases_score -- --exactpasses; failure -cargo test -p news-core importance_is_deterministic -- --exact(calls the function twice with identical inputs, assertsassert_eq!(a, b)) passes. Evidence.omo/evidence/task-3-news-triage.txt. Commit: Y | feat(news-core): add importance scoring with corroboration saturation -
4. news-core: relevance scoring interface + Naive Bayes model with cold-start fallback What to do: In
crates/news-core/src/scoring/relevance.rs, definepub trait Bm25Source { fn bm25_rank(&self, item_id: Uuid) -> Option<f64>; }(implemented later by news-store against FTS5'sbm25()), andpub struct NaiveBayesModel { feature_counts: HashMap<String, (u32,u32)>, /* (interested, not_interested) */ total_interested: u32, total_not_interested: u32 }withpub fn train(&mut self, tokens: &[String], feedback: FeedbackKind)andpub fn score(&self, tokens: &[String]) -> f64returning a log-odds relevance contribution. Implementpub fn relevance_score(bm25: f64, bayes: &NaiveBayesModel, tokens: &[String]) -> f64that returnsbm25alone (Bayes term contributes exactly 0.0) wheneverbayes.total_interested + bayes.total_not_interested < 20, andbm25 + bayes.score(tokens)otherwise. TDD: write a failing test first asserting the cold-start behavior at exactly 19 vs 20 samples (boundary test), then implement. Must NOT do: no persistence here (news-store owns loading/saving the model); no dependency onnews-storeorsqlxin this crate. Parallelization: Wave 1 | Blocked by: 2 | Blocks: 10 References:.omo/drafts/news-triage.mdline 45 (Relevance learning: BM25+Bayes, cold-start boundary at 20 samples, neutral not suppressive), line 68 (D2). Acceptance criteria:cargo test -p news-core scoring::relevanceexits 0 including a test named exactly asserting the 19-vs-20-sample boundary (cold_start_boundary_at_20_samplesor equivalent name containing "cold_start"). QA scenarios: happy - with 20 trained samples,relevance_scoreoutput differs frombm25input by a nonzero Bayes term, evidence.omo/evidence/task-4-news-triage.txt; failure - with 0 samples,relevance_score(5.0, &empty_model, &tokens) == 5.0exactly (bit-identical to bm25 input), evidence same file. Commit: Y | feat(news-core): add BM25+NaiveBayes relevance scoring with cold-start fallback -
5. news-store: SQLite schema/migrations including FTS5 virtual table What to do: In
crates/news-store/migrations/, add sqlx migration files (0001_init.sqletc.) creating tables:sources,raw_items,story_clusters,cluster_members,feedback,bayes_model(serialized feature counts),percentile_stats(metric_kind, percentile_bucket, value, computed_at),notification_log(for budget/dedupe/suppression tracking), and an FTS5 virtual tableraw_items_fts USING fts5(title, summary, content='raw_items', content_rowid='rowid', tokenize='porter')with triggers to keep it synced on insert/update/delete. Incrates/news-store/src/lib.rs, callsqlx::migrate!("./migrations")from apub async fn init_db(url: &str) -> Result<SqlitePool, StoreError>that also runsPRAGMA journal_mode=WAL;. Must NOT do: no hand-written migration runner; no rawCREATE TABLE IF NOT EXISTSoutside the migrations directory. Parallelization: Wave 2 | Blocked by: 2 | Blocks: 6,7,8,9 References:/home/connor/docs/projects/runway/Dockerfile:90-99(migrations compiled in viasqlx::migrate!, run at startup, no entrypoint script),https://sqlite.org/fts5.htmlsection 5.1.1 (bm25(),content=external-content tables, porter tokenizer),.omo/drafts/news-triage.mdline 17 (C3-score evidence), line 46 (data retention). Acceptance criteria:cargo test -p news-store init_db_runs_migrationspasses against an in-memorysqlite::memory:pool, assertingsqlx::query("SELECT name FROM sqlite_master WHERE type='table'")includes all 7 named tables plusraw_items_fts. QA scenarios: happy - insert a row intoraw_items, thenSELECT rowid FROM raw_items_fts WHERE raw_items_fts MATCH 'test'returns it (trigger sync works), evidence.omo/evidence/task-5-news-triage.txt; failure - run migrations twice against the same pool and assert the second run is a no-op (sqlx::migrate!idempotency), evidence same file. Commit: Y | feat(news-store): add SQLite schema, migrations, FTS5 index with sync triggers -
6. news-store: source registry repository (CRUD, weight/enable/poll-interval) What to do: In
crates/news-store/src/sources.rs, implementpub struct SourceRepo(SqlitePool)withasync fn upsert(&self, source: &news_core::Source) -> Result<(), StoreError>,async fn list_enabled(&self) -> Result<Vec<news_core::Source>, StoreError>,async fn set_enabled(&self, id: Uuid, enabled: bool) -> Result<(), StoreError>. Seed the 3 v1 sources (Al Jazeerahttps://www.aljazeera.com/xml/rss/all.xmlkind=RssAtom poll=300s, BBC Worldhttps://feeds.bbci.co.uk/news/world/rss.xmlkind=RssAtom poll=900s honoring its declared ttl 15, AP news-sitemaphttps://apnews.com/news-sitemap-content.xmlkind=NewsSitemap poll=300s) via a seed migration or aseed_default_sources()helper called once at first startup (idempotent). Must NOT do: do not hardcode Reuters as enabled — add it as a disabled, commented-out example in the config template (todo 18/21), not in code. Parallelization: Wave 2 | Blocked by: 5 | Blocks: 8 (uses source list for polling) References:.omo/drafts/news-triage.mdline 43 (Sources v1 exact URLs/poll cadence), line 44 (per-source poll interval honoring TTL). Acceptance criteria:cargo test -p news-store sources::seed_default_sources_is_idempotentpasses: calling it twice results in exactly 3 rows insources, not 6. QA scenarios: happy -SourceRepo::list_enabledreturns exactly 3 sources with the exact URLs above, evidence.omo/evidence/task-6-news-triage.txt; failure -set_enabled(bbc_id, false)thenlist_enabledreturns 2, evidence same file. Commit: Y | feat(news-store): add source registry repository with v1 seed sources -
7. news-ingest: feed polling client (RSS/Atom + news-sitemap parsers, conditional GET, backoff, honest UA) What to do: In
crates/news-ingest/src/lib.rs, implementpub struct FeedPoller { client: reqwest::Client }withpub async fn poll(&self, source: &Source, etag: Option<&str>, last_modified: Option<&str>) -> Result<PollResult, IngestError>wherePollResultis one ofNotModified,Items(Vec<RawItem>, new_etag: Option<String>, new_last_modified: Option<String>),RateLimited(retry_after: Option<Duration>). SendIf-None-Match/If-Modified-Sinceheaders when provided; parse RSS/Atom viaquick-xml(already a workspace dep per runway precedent) extracting title/link/pubDate/category; parse the AP news-sitemap's<url><loc>/<news:title>/<news:publication_date>structure. SetUser-Agent: news-triage/0.1 (+https://news.rcjohnstone.com; personal feed reader)— an honest, identifying UA per the AP robots.txt finding. On HTTP 429 or 5xx, returnRateLimitedwithRetry-Afterparsed if present, exponential backoff computed by the caller (news-server scheduler, todo 18). Must NOT do: no following ofDisallow-listed AP RSS path (/index.rss,/api/v2/feed/) — only the sitemap path is ever requested for AP. Parallelization: Wave 2 | Blocked by: 5 | Blocks: 8 References: verified live 2026-08-31: AJall.xml-> 200, BBC world rss -> 200 ttl 15, APindex.rss-> 401, AProbots.txt->Disallow: /*.rss,Disallow: /api/v2/feed/, advertisesSitemap: https://apnews.com/news-sitemap-content.xml-> verified 200 withnews:title+news:publication_date..omo/drafts/news-triage.mdlines 43-44. Acceptance criteria:cargo test -p news-ingestexits 0 with fixture-based tests (saved XML snapshots incrates/news-ingest/tests/fixtures/) for: RSS parse extracts correct title/link/pubDate, news-sitemap parse extracts correct title/publication_date, a 304 response maps toPollResult::NotModified, a 429 withRetry-After: 30maps toRateLimited(Some(Duration::from_secs(30))). QA scenarios: happy -cargo test -p news-ingest parses_bbc_fixturepasses against a committed BBC RSS snapshot, evidence.omo/evidence/task-7-news-triage.txt; failure -cargo test -p news-ingest rate_limited_on_429passes, evidence same file. Also run one LIVE smoke:curl -sI -A "news-triage/0.1 (+https://news.rcjohnstone.com; personal feed reader)" https://www.aljazeera.com/xml/rss/all.xml | head -1must show200, captured in the evidence file. Commit: Y | feat(news-ingest): add RSS/Atom + news-sitemap poller with conditional GET and backoff -
8. news-store: story ingestion repository (insert raw item, populate FTS5) What to do: In
crates/news-store/src/items.rs, implementpub struct ItemRepo(SqlitePool)withasync fn insert_if_new(&self, item: &RawItem) -> Result<InsertOutcome, StoreError>(dedupes on(source_id, link)unique constraint from todo 5's schema;InsertOutcome::InsertedorAlreadyExists), andasync fn search_fts(&self, query: &str, limit: u32) -> Result<Vec<(RawItem, f64)>, StoreError>returning(item, bm25_score)viaSELECT ..., bm25(raw_items_fts) AS rank FROM raw_items_fts WHERE raw_items_fts MATCH ?1 ORDER BY rank LIMIT ?2(SQLite'sbm25()returns lower-is-better; the caller in todo 10 negates/normalizes it). Must NOT do: no full-text indexing of anything beyond title+summary (no article body — Scope OUT). Parallelization: Wave 2 | Blocked by: 6,7 | Blocks: 9,10 References:https://sqlite.org/fts5.html(bm25() ranking function,ORDER BY rankascending = best match first),.omo/drafts/news-triage.mdline 17. Acceptance criteria:cargo test -p news-store items::insert_if_new_dedupes_on_source_and_linkpasses: inserting the same(source_id, link)twice returnsAlreadyExistson the second call and the table has exactly 1 row. QA scenarios: happy - insert 3 items with distinct titles,search_fts("election")on one containing that word returns it ranked, evidence.omo/evidence/task-8-news-triage.txt; failure -search_fts("zzz_no_match")returns an empty vec (not an error), evidence same file. Commit: Y | feat(news-store): add item repository with FTS5-backed search and dedupe -
9. news-core + news-store: clustering + corroboration counting What to do: In
crates/news-core/src/clustering.rs, implement a purepub fn similarity(a: &RawItem, b: &RawItem) -> f64(title token-overlap / Jaccard, since no LLM embeddings are allowed) andpub fn should_cluster(sim: f64, threshold: f64) -> bool. Incrates/news-store/src/clusters.rs, implementpub struct ClusterRepo(SqlitePool)withasync fn assign_or_create(&self, item: &RawItem, threshold: f64) -> Result<StoryCluster, StoreError>: finds an existing open cluster (last_seen_at within 45 min) whose canonical item's title similarity to the new item exceedsthreshold, adds the item as a member and incrementssource_countif the new item'ssource_idis not already a member of that cluster (source_count counts DISTINCT source_ids, not item count — this is what makes the AP-syndicated-widely case and the "AJ repeats the same event twice" case both correct); otherwise creates a new cluster. Must NOT do: no clustering across the 45-minute window boundary (an item outside the window always starts a new cluster, even if textually similar — this is what keeps corroboration_count meaningful for the breaking-bypass rule). Parallelization: Wave 2 | Blocked by: 8 | Blocks: 11 (importance scorer needs corroboration_count) References:.omo/drafts/news-triage.mdline 16 (C2-cluster: "AP copy syndicated widely; AJ feed repeats same event twice"), line 41 (breaking bypass: corroboration >= 2-of-N sources within 45 min — DISTINCT source count, not item count). Acceptance criteria:cargo test -p news-store clusters::source_count_is_distinct_sources_not_item_countpasses: two items from the SAME source clustered together yieldsource_count == 1; two items from DIFFERENT sources clustered together yieldsource_count == 2. QA scenarios: happy - simulate the "AJ repeats the same event twice" case (2 AJ items, high similarity) -> 1 cluster,source_count == 1, evidence.omo/evidence/task-9-news-triage.txt; failure - an item published 46 minutes after a similar cluster'slast_seen_atstarts a NEW cluster, not joining the old one, evidence same file. Commit: Y | feat(news-core,news-store): add title-similarity clustering with distinct-source corroboration counting -
10. news-server: relevance scorer wiring (BM25 via FTS5 + Naive Bayes) What to do: In
crates/news-server/src/scoring.rs, implementpub struct RelevanceScorer { item_repo: ItemRepo, bayes: NaiveBayesModel }wrappingnews_core::scoring::relevance::relevance_score, loading/saving theNaiveBayesModelto/from thebayes_modeltable (todo 5's schema) on each score/train call. Wirenews_store::ItemRepo::search_fts's bm25 rank (negate: SQLite'sbm25()is ascending-better, so use-rankor1.0/(1.0+rank)to get an ascending-worse-to-better score consistent withimportance_score's convention — pick one, document it in a doc comment, and keep it consistent everywhere else this crate reads a relevance score). Apply the blocklist veto here: if any configured blocklist term matches the item's title/summary (case-insensitive substring), return relevancef64::NEG_INFINITY(never notify-eligible) regardless of BM25/Bayes. Parallelization: Wave 3 | Blocked by: 4,8 | Blocks: 12,13 References:.omo/drafts/news-triage.mdline 45 (blocklist is a hard veto),https://sqlite.org/fts5.html(bm25() sign convention). Acceptance criteria:cargo test -p news-server scoring::blocklist_veto_returns_neg_infinitypasses: an item whose title contains a blocklisted term scoresf64::NEG_INFINITYregardless of its BM25/Bayes inputs. QA scenarios: happy - an item matching a configured "interest" keyword scores higher than one that doesn't, both non-blocklisted, evidence.omo/evidence/task-10-news-triage.txt; failure - a blocklisted item's score isNEG_INFINITYeven when its Bayes score would otherwise be maximally positive, evidence same file. Commit: Y | feat(news-server): wire relevance scorer with FTS5 BM25, Bayes, and blocklist veto -
11. news-server: importance scorer wiring + trailing-7-day percentile stats tracker What to do: In
crates/news-server/src/scoring.rs(extend), implementpub struct ImportanceScorerwrappingnews_core::scoring::importance::importance_score, fed byClusterRepo'ssource_countand each source'sweight. Incrates/news-store/src/percentile.rs, implementpub struct PercentileTracker(SqlitePool)withasync fn record(&self, metric_kind: MetricKind, value: f64) -> Result<(), StoreError>(appends topercentile_stats) andasync fn percentile(&self, metric_kind: MetricKind, p: f64, window_days: u32) -> Result<f64, StoreError>computing the Nth percentile over rows within the trailing window viaSELECT value FROM percentile_stats WHERE metric_kind = ?1 AND computed_at >= ?2 ORDER BY valueand interpolating at indexceil(p/100 * count) - 1. TwoMetricKindvariants:Importance,Relevance, each with its own independent trailing-7-day distribution as decided in the draft. Must NOT do: do not conflate the two distributions — the breaking-bypass check (todo 12) reads the Importance percentile at p=99; the adaptive notify threshold (also todo 12) reads the Relevance percentile at p=90. These are two separate queries against two separately-tagged rows, never mixed. Parallelization: Wave 3 | Blocked by: 4,8 | Blocks: 12 References:.omo/drafts/news-triage.mdline 42 (adaptive percentile threshold: two distinct distributions, importance p99 for bypass vs relevance p90 for notify-eligible, same recompute mechanism, recomputed once per poll cycle). Acceptance criteria:cargo test -p news-store percentile::importance_and_relevance_are_independent_distributionspasses: recording 10 importance values and 10 different relevance values, then assertingpercentile(Importance, 90.0, 7)andpercentile(Relevance, 90.0, 7)return different values matching each metric's own data. QA scenarios: happy - record 100 relevance values 1.0..100.0,percentile(Relevance, 90.0, 7)returns approximately 90.0 (within interpolation tolerance), evidence.omo/evidence/task-11-news-triage.txt; failure -percentile()on a metric_kind with zero recorded rows in the window returns an explicitErr(StoreError::InsufficientData), not a panic or a default 0.0, evidence same file. Commit: Y | feat(news-server,news-store): wire importance scorer and trailing-7-day percentile tracker -
12. news-server: notification gate (token bucket, quiet hours, adaptive threshold, breaking bypass, suppression) What to do: In
crates/news-server/src/gate.rs, implementpub struct NotificationGate { budget: TokenBucket, config: NotifyConfig }withpub async fn evaluate(&mut self, cluster: &StoryCluster, score: &ScoreBreakdown, now: DateTime<Tz>) -> GateDecisionwhereGateDecisionis one ofNotify,Suppress(reason: SuppressReason),Digest(queued for C7). Logic, in order: (1) if this cluster already notified (checknotification_log),Suppress(AlreadyNotified)unless a material-update rule fires (defined as:source_countincreased by >= 2 since last notify) — then allow a secondNotify; (2) breaking bypass check FIRST — ifscore.importance_percentile >= 99.0OR cluster's distinct-source corroboration count meets the 2-of-N-within-45-min rule, check the SEPARATE bypass-lane counter (max 4 per rolling 24h, its ownnotification_logtag) — if under that ceiling,Notifyimmediately, bypassing quiet hours AND the normal token bucket; if the bypass ceiling itself is exhausted, fall through to normal path; (3) normal path — ifnowis within quiet hours (22:00-07:00 America/Louisville) orscore.relevance_percentile < 90.0,SuppressorDigest(digest if between 75th-90th percentile, else full Suppress); (4) if the token bucket (2/day continuous refill, burst 3) has no tokens,Suppress(BudgetExhausted); else consume a token andNotify. Must NOT do: do not let the bypass lane consume from or refill the normal token bucket — they are fully independent counters, per the D4/D9 decision. Parallelization: Wave 3 | Blocked by: 10,11 | Blocks: 15,16 References:.omo/drafts/news-triage.mdline 40 (token bucket exact numbers, continuous refill), line 41 (bypass ceiling: max 4/24h, independent, corroboration 2-of-N, importance p99), lines 66-67 (D4, D6: material-update rule, independent bypass cap). Acceptance criteria:cargo test -p news-server gate::bypass_ceiling_is_independent_of_normal_budgetpasses: exhaust the normal token bucket to 0, then feed 5 breaking-tier stories in one 24h window — assert exactly 4 returnNotify(bypass ceiling) and the 5th returnsSuppress, with the normal bucket's token count unaffected throughout. QA scenarios: happy - a story at relevance_percentile 95 outside quiet hours with tokens available returnsNotify, evidence.omo/evidence/task-12-news-triage.txt; failure - a story at relevance_percentile 80 (digest band) returnsDigest, notNotifyor fullSuppress, evidence same file; also test the material-update case: same cluster notified once, then source_count grows by 3, secondevaluatecall returnsNotifyagain. Commit: Y | feat(news-server): implement notification gate with independent bypass ceiling -
13. news-server: feedback capture endpoint + Naive Bayes retrain hook What to do: In
crates/news-server/src/api/feedback.rs, implementPOST /api/feedback(axum handler) accepting{ "story_cluster_id": "<uuid>", "kind": "interested" | "not_interested" }, persisting via aFeedbackRepo(news-store, mirrors todo 6/8's repo pattern) and callingRelevanceScorer::train(todo 10) with the cluster's tokens, updating the persistedNaiveBayesModelrow. Return200 {"status":"recorded"}on success,400with an error body on an unknownstory_cluster_id,422on a malformedkind. Must NOT do: no synchronous full model retrain from scratch on every feedback call — incrementaltrain()(single-item update) only, per news-core'sNaiveBayesModel::trainsignature from todo 4. Parallelization: Wave 3 | Blocked by: 12 | Blocks: 14 (digest reads feedback history for content selection is optional; hard blocker is 15/17 needing the full API surface stable) References:.omo/drafts/news-triage.mdline 65 (feedback capture persisted, fed into relevance scoring),/home/connor/.local/bin/movie_recs_notify:40-55(ntfy action-button precedent for what a feedback callback URL shape looks like). Acceptance criteria:cargo test -p news-server api::feedback::unknown_cluster_id_returns_400andapi::feedback::malformed_kind_returns_422both pass viaaxum::body+tower::ServiceExt::oneshotin-process request tests (no real HTTP listener needed). QA scenarios: happy -curl -s -X POST http://localhost:3000/api/feedback -H 'Content-Type: application/json' -d '{"story_cluster_id":"<real-uuid>","kind":"interested"}'against the running dev server returns{"status":"recorded"}, evidence.omo/evidence/task-13-news-triage.txt(paste curl -i output); failure - same curl with a garbage UUID returns HTTP 400, evidence same file. Commit: Y | feat(news-server): add feedback capture endpoint with incremental Bayes retrain -
14. news-server: config loader with hot-reload on SIGHUP What to do: In
crates/news-server/src/config.rs, defineNewsConfig(serde, matches the TOML schema:[[sources]]url/kind/weight/enabled/poll_interval_secs override,[notify]notify_opinions/quiet_hours_start/quiet_hours_end/budget_refill_per_day/budget_burst/bypass_ceiling_per_day,[topics]interests/blocklist) parsed from/config/config.toml(mounted path) viatoml::from_str. Implementpub async fn watch_sighup(config: Arc<RwLock<NewsConfig>>, path: PathBuf)usingtokio::signal::unix::signal(SignalKind::hangup())to re-parse and swap the config on SIGHUP, logging (viatracing) either the new config summary or a parse error WITHOUT crashing the process (keep serving the last-good config on a bad reload). Must NOT do: no partial config application — a reload either fully replaces the in-memoryNewsConfigor is fully rejected; never a half-applied merge. Parallelization: Wave 3 | Blocked by: 12 | Blocks: 16 (digest reads notify_opinions/quiet_hours from this config) References:.omo/drafts/news-triage.mdline 47 (config file path, format, exact key names, hot-reload on SIGHUP). Acceptance criteria:cargo test -p news-server config::bad_reload_keeps_last_good_configpasses: write a valid config, load it, then overwrite the file with invalid TOML and send a simulated reload — assert the in-memory config is unchanged and an error was logged (capture viatracing_testor a custom subscriber). QA scenarios: happy - valid config withnotify_opinions = trueloads andconfig.notify.notify_opinions == true, evidence.omo/evidence/task-14-news-triage.txt; failure - malformed TOML ([notifymissing closing bracket) is rejected with a parse error surfaced in logs, process does not exit, evidence same file. Commit: Y | feat(news-server): add TOML config loader with SIGHUP hot-reload -
15. news-server: ntfy publisher (title/markdown/priority/click/action buttons) + ACL grant snippet doc What to do: In
crates/news-server/src/notify/ntfy.rs, implementpub struct NtfyPublisher { base_url: Url, topic: String, auth: BasicAuth }withpub async fn publish(&self, story: &PublishableStory) -> Result<(), NotifyError>sending aPOSTto{base_url}/{topic}with headersTitle,Priority(mapped: breaking-bypass Notify -> 5, normal Notify -> 3),Tags,Markdown: yes,Click(article link), andActions(view-button to the article, http-buttons toPOST /api/feedbackfor Interested/NotInterested — mirrorsmovie_recs_notify's header shape). ReadNTFY_TOKEN/NTFY_USER/NTFY_PASSfrom env (per the${VAR}-from-compose secrets decision). Createdeploy/ntfy-access-snippet.mddocumenting the exact command:ntfy access <news-user> news-triage rw(or the equivalent for the confirmedauth-default-access: deny-allposture) for the operator to run once, referencing the confirmed server.yml setting. Must NOT do: no retry-forever loop on ntfy publish failure — one retry with backoff, then log and move on (a failed notify must never crash the poll loop). Parallelization: Wave 4 | Blocked by: 13 | Blocks: 17,20 References:/home/connor/.local/bin/movie_recs_notify:40-55(exact header names, Basic auth pattern, priority 1-5 semantics, click/actions),data/ntfy/etc/server.yml:10(auth-default-access: "deny-all", confirmed live),.omo/drafts/news-triage.mdline 38 (ntfy ACL provisioning decision). Acceptance criteria:cargo test -p news-server notify::ntfy::request_has_required_headerspasses using awiremock(orhttptest) mock server asserting the outgoing request hasTitle,Priority,Markdown: yes,Click, and at least 2Actionsentries. QA scenarios: happy - against the mock server, a breaking-bypassNotifysetsPriority: 5; a normalNotifysetsPriority: 3, evidence.omo/evidence/task-15-news-triage.txt; failure - a publish that gets a 500 from the mock server retries exactly once then returnsErrwithout panicking, evidence same file. Also producedeploy/ntfy-access-snippet.mdand paste its content into the evidence file. Commit: Y | feat(news-server): add ntfy publisher with action buttons and ACL operator snippet -
16. news-server: daily digest (C7) What to do: In
crates/news-server/src/notify/digest.rs, implementpub async fn build_and_send_digest(item_repo: &ItemRepo, ntfy: &NtfyPublisher, config: &NewsConfig, now: DateTime<Tz>) -> Result<(), NotifyError>, gated to run once whennow's local time crosses 07:00 America/Louisville (track "already sent today" via anotification_logrow taggeddigest), querying stories from the prior 24h whoserelevance_percentilefell between 75.0 and 90.0 (theDigestGateDecisionfrom todo 12), excluding opinion pieces unlessnotify_opinions, and sending ONE low-priority (Priority 2) ntfy message with a markdown bullet list (title + link per story), which does NOT consume the normal token bucket. Must NOT do: do not send a digest with zero eligible stories (skip silently, log at debug level, no empty ntfy message). Parallelization: Wave 4 | Blocked by: 12,14 | Blocks: 17 References:.omo/drafts/news-triage.mdline 52 (digest exact time, content band 75th-90th percentile, outside budget), lines 85 (Scope IN). Acceptance criteria:cargo test -p news-server digest::skips_when_no_eligible_storiespasses: with zero stories in the 75-90 percentile band,build_and_send_digestreturnsOk(())without calling the (mocked) ntfy publisher. QA scenarios: happy - 3 stories in-band produce one ntfy call with a markdown body listing all 3 titles, evidence.omo/evidence/task-16-news-triage.txt(mock server captured request body); failure - callingbuild_and_send_digesttwice within the same day sends only once (idempotency via thenotification_log"digest" tag check), evidence same file. Commit: Y | feat(news-server): add daily digest for sub-threshold-but-interesting stories -
17. news-cli: replay harness + --dry-run server flag (C8) What to do: In
crates/news-cli/src/main.rs(clap-derived), implement subcommandreplay <fixture-dir>that reads saved feed XML snapshots from<fixture-dir>(same fixture format as todo 7's tests), runs them through ingestion -> clustering -> scoring -> gate (in-memory SQLite, never touching the real DB), and prints a table (story title, importance, relevance, percentile,GateDecision, reason) to stdout — never calls ntfy. Incrates/news-server/src/main.rs, add a--dry-runCLI flag (clap) that runs the full live poll loop against real feeds but routes everyGateDecision::Notify/Digestthrough the same stdout-printing path instead ofNtfyPublisher::publish. Must NOT do:--dry-runmust not write tonotification_logas if a real notification happened — it logs the decision but the row is taggeddry_run=trueso a subsequent real run isn't affected by suppression state from a dry run. Parallelization: Wave 4 | Blocked by: 15,16 | Blocks: 18,19 References:.omo/drafts/news-triage.mdline 53 (C8 exact CLI invocation and behavior), line 78 (D7: replay harness doubles as the tuning tool). Acceptance criteria:cargo run -p news-cli -- replay crates/news-cli/tests/fixtures/sample-week/exits 0 and prints at least one row containing aGateDecisionvariant name. QA scenarios: happy -cargo run -p news-cli -- replay <fixtures>produces deterministic output (run twice, byte-identical stdout given the same fixtures and a fixednow), evidence.omo/evidence/task-17-news-triage.txt; failure ---dry-runagainst real feeds for one poll cycle produces console output and adry_run=truenotification_log row, and a SUBSEQUENT non-dry-run poll cycle is NOT suppressed by that dry-run row, evidence same file. Commit: Y | feat(news-cli,news-server): add replay harness and --dry-run flag -
18. news-server: axum wiring (/healthz, /metrics, scheduler, main.rs) What to do: In
crates/news-server/src/main.rs, wire:GET /healthzreturning200 {"status":"ok","db":"ok"}after aSELECT 1round-trip against the SQLite pool (500 with{"status":"error","db":"<message>"}if the round-trip fails);GET /metricsreturning Prometheus text format via aprometheuscrateRegistryexposing at minimum:news_poll_total{source},news_notify_total{lane="normal"|"bypass"|"digest"},news_suppress_total{reason},news_scrape_duration_seconds. Scheduler: atokio::time::interval-driven loop per enabled source respecting itspoll_interval_secs(todo 6), callingFeedPoller::poll(todo 7) ->ItemRepo::insert_if_new(todo 8) ->ClusterRepo::assign_or_create(todo 9) -> scorers (todos 10,11) ->NotificationGate::evaluate(todo 12) -> publish/digest (todos 15,16), plus a daily 03:00 America/Louisville pruning job deletingraw_itemsolder than 90 days (keepingstory_clusters/feedback/bayes_modelindefinitely). On aFeedPollerRateLimitedresult, apply exponential backoff to that source's next poll only (not global). Must NOT do: no blocking the whole scheduler loop on one slow/failing source — each source's poll runs in its own spawned task with its own error boundary. Parallelization: Wave 4 | Blocked by: 4,8 | Blocks: 19 (config from todo 14 is a listed dep but not the hard blocker for this todo's own compile) References:.omo/drafts/news-triage.mdline 49 (healthcheck exact path/response/compose block), line 39 (metrics: implementer exposes /metrics only), line 48 (data retention: 90-day pruning job, daily), line 44 (per-source poll interval, backoff on 429/5xx applies per-source). Acceptance criteria:cargo test -p news-server main::healthz_returns_200_when_db_reachableandmain::pruning_job_deletes_items_older_than_90_days_keeps_newerboth pass (in-process request test for healthz; direct repo call + assertion for pruning). QA scenarios: happy -curl -s http://localhost:3000/healthzagainst the running dev container returns{"status":"ok","db":"ok"}, evidence.omo/evidence/task-18-news-triage.txt(paste curl output); failure -curl -s http://localhost:3000/metrics | grep news_poll_totalshows the metric present with a numeric value after at least one poll cycle has run, evidence same file. Commit: Y | feat(news-server): wire healthcheck, metrics, per-source scheduler, and retention pruning -
19. news-web: Leptos web UI (story list, suppression reasons, feedback, config view) What to do: In
crates/news-web/src/, build a Leptos 0.8 CSR app (Trunk + Tailwind v4, matchingrunway-web's toolchain) with: a story list view callingGET /api/stories?since=<ts>(add this read-only endpoint alongside todo 13's feedback endpoint in news-server) showing title, score, andGateDecision/suppression reason per story; feedback buttons callingPOST /api/feedback(todo 13); a read-only config view callingGET /api/config(add this endpoint too) rendering the currentNewsConfig(todo 14) as a formatted summary (sources, thresholds, quiet hours) — editing is out of scope for v1 UI (config is file-edited + SIGHUP, per the draft), so this view is display-only. All API calls use relative/api/...paths (no baked-in absolute URL), matching the runway lesson. Must NOT do: no localStorage-as-global-state; no client-side duplication of scoring/gate logic — the UI only displays what the server already decided. Parallelization: Wave 5 | Blocked by: 17,18 | Blocks: 21 References:/home/connor/docs/projects/runway/Dockerfile:13-16(relative/api/...addressing, explicit lesson from v1),runway/README.md"Frontend" section (Trunk serve, Tailwind v4 setup),.omo/drafts/news-triage.mdline 81 (Scope IN: tiny local Leptos web UI for reviewing stories, suppression reasons, feedback). Acceptance criteria:cd crates/news-web && trunk build --releaseexits 0 and produces adist/directory containing a.wasmfile. QA scenarios: happy -/playwrightskill:trunk servethe dev build, load the page, assert the story list renders at least one row and clicking "Interested" on a story triggers a network request to/api/feedbackwith a 200 response, evidence.omo/evidence/task-19-news-triage.png(screenshot) +.omo/evidence/task-19-news-triage.txt(console log, network log); failure - load the page with the backend/api/storiesreturning a 500 (simulate via a mock), assert the UI shows a visible error state instead of a blank/broken page, evidence same files. Commit: Y | feat(news-web): add Leptos story list, feedback, and config views -
20. Dockerfile (one image, dist-lift build) + finalize CI bundle job What to do: Write
/home/connor/docs/projects/news/Dockerfilemirroringrunway/Dockerfile's structure exactly: awebbuild stage (rust:1.98-slim-bookworm base or newer, matching the pinned toolchain from todo 1, Node 22 from a pinned image for Tailwind v4's oxide binary, Trunk as a released binary,trunk build --releaseproducingcrates/news-web/dist), aserverbuild stage (cargo build --release --locked -p news-server), and aruntimestage (debian:bookworm-slim,ca-certificates+tzdata, copies the server binary ANDCOPY --from=web /app/crates/news-web/dist /srv/dist— explicitly documented as "nothing serves it from inside this container", mirroring the runway comment verbatim in intent).EXPOSE 3000,ENV NEWS_DATABASE_URL=sqlite:///db/news.db,RUN mkdir -p /db,CMD ["news-server"]. Update.gitea/workflows/ci.yml'sbundlejob (stubbed in todo 1) to fully matchrunway/.gitea/workflows/ci.yml:86-121: install Trunk,npm ci,trunk build --releaseincrates/news-web, then assert the produced.wasmfile is under a 1.8MB budget, failing the job with the byte overage printed if exceeded. Must NOT do: the runtime stage must not include Node, npm, Trunk, or any Rust build tooling — only the final binary + tzdata + ca-certificates, matching runway's multi-stage discipline. Parallelization: Wave 5 | Blocked by: 17 | Blocks: 21 References:/home/connor/docs/projects/runway/Dockerfile:1-105(full multi-stage structure, verbatim pattern to mirror),/home/connor/docs/projects/runway/.gitea/workflows/ci.yml:86-121(bundle job, exact 1.8MB budget check logic),.omo/drafts/news-triage.mdline 50 (WASM bundle budget decision). Acceptance criteria:podman build -t news-triage:test /home/connor/docs/projects/newsexits 0;podman run --rm news-triage:test news-server --version(or equivalent smoke flag) runs without a missing-shared-library error. QA scenarios: happy - after build,podman run --rm news-triage:test ls /srv/distshows anindex.htmland at least one.wasmfile, evidence.omo/evidence/task-20-news-triage.txt; failure - temporarily inflate a dependency to push the WASM bundle over 1.8MB and confirm thebundleCI job step fails with the byte-overage message, then revert, evidence same file. Commit: Y | feat(build): add multi-stage Dockerfile and finalize CI WASM bundle-size gate -
21. Operator snippets: compose service, Caddy LAN vhost, ntfy ACL, Prometheus scrape, deploy timer What to do: Create
deploy/README.md(mirroringrunway/deploy/README.md's structure) documenting, as copy-pasteable snippets the OPERATOR applies (never auto-applied by the implementer): (1) anews-backendservice block for~/compose.yml— imagegit.rcjohnstone.com/connor/news:latest,restart: unless-stopped,networks: [internal],environment:(NEWS_DATABASE_URL,TZ=America/Louisville, ntfy creds as${VAR}),volumes:(./data/news/db:/db,./config/news:/config:ro),healthcheck:block per todo 18's/healthz; (2) anews.rcjohnstone.comCaddyfile block matching the LAN-only pattern (internal CA, no# ddns: publicmarker,reverse_proxy news-backend:3000for/api/*, static file serving withroot * /srv/news/dist+index.htmlfallback for everything else, mounting./data/news/web:/srv/news:ro— mirroring the "mount the holder not dist itself" atomic-rename lesson); (3) the exactntfy accessgrant command for the confirmeddeny-alldefault; (4) a Prometheus scrape-config snippet for./config/prometheus/prometheus.yml(job_name: news-triage, targetnews-backend:3000, path/metrics); (5) adeploy/news-updatescript + systemd system timer unit pair (mirroringrunway/deploy/runway-update.service/.timerexactly: root timer, pulls the image, extractsdistto~/data/news/webvia the same atomic-rename discipline, restarts the container). Must NOT do: this todo itself must not modify~/compose.yml,~/Caddyfile,~/data/ntfy/etc/server.yml, or~/config/prometheus/prometheus.yml— it only WRITES the snippet files inside the repo for the operator to apply by hand. Parallelization: Wave 5 | Blocked by: 3,19,20 | Blocks: F1-F4 References:/home/connor/docs/projects/runway/deploy/README.md:1-187(full structure to mirror: compose block, Caddy mount-the-holder lesson, systemd system timer, rollback procedure),/home/connor/compose.yml:1016-1018(grafana no-ddns-public-marker pattern),/home/connor/Caddyfile:389-395(llama-swap internal-CA + source-IP-gating pattern),/home/connor/compose.yml:1053,1064(prometheus scrape config mount path),.omo/drafts/news-triage.mdlines 34-39,97 (Deploy/Exposure/Secrets/ntfy-ACL/Metrics decisions, Scope-OUT protected files). Acceptance criteria:test -f deploy/README.md && grep -q "ntfy access" deploy/README.md && grep -q "internal" deploy/README.md && grep -q "prometheus.yml" deploy/README.mdall pass (a lightweight structural check that every required snippet section is present). QA scenarios: happy - a human (simulated by an agent) followsdeploy/README.mdstep-by-step against a scratch copy of~/compose.yml/~/Caddyfilein/tmp/opencodeand confirms the pasted snippets produce valid YAML/Caddyfile syntax (podman-compose -f /tmp/opencode/compose.yml configandcaddy validate --config /tmp/opencode/Caddyfileboth exit 0), evidence.omo/evidence/task-21-news-triage.txt; failure - confirm none of the 4 protected files in the real~/were touched by this todo (git -C /home/connor status— wait, these aren't in a git repo; instead assert viastattimestamps unchanged before/after), evidence same file. Commit: Y | docs(deploy): add operator-applied compose/Caddy/ntfy/prometheus/systemd snippets
Final verification wave
Runs in parallel after ALL todos. ALL must APPROVE. Surface results and wait for the user's explicit okay before declaring complete.
- F1. Plan compliance audit
What to verify: every todo 1-21 is checked, every "Must NOT do" clause was honored (grep the diff/repo for violations: no LLM/LiteLLM/llama.cpp references, no edits to
~/compose.yml/~/Caddyfile/~/data/ntfy/etc/server.yml/~/config/prometheus/prometheus.yml, no article-body scraping code, no backend code servingdist), and every Scope-IN item from the plan's## Scopesection has a corresponding implemented component. Tool + invocation:grep -riE "liteLLM|llama.cpp|gpt-oss|gemma3" /home/connor/docs/projects/news/cratesexpect zero matches; diff-style check that the 4 protected files' mtimes are unchanged from before implementation started; read.omo/plans/news-triage.mdand count- [x]vs- [ ]across todos 1-21. Verdict: APPROVE only if all 21 todos checked, zero forbidden-string matches, zero protected-file mtime changes. - F2. Code quality review
What to verify: read every file under
crates/*/src/,Dockerfile,.gitea/workflows/ci.yml,deploy/line by line; confirm no stubs/TODOs/unimplemented!()/todo!()remain, nounwrap()/expect()outside test modules (workspace lint should already deny this — confirm the lint is not suppressed anywhere via#[allow(clippy::unwrap_used)]), and that the importance/relevance/gate math matches the formulas documented in the draft. Tool + invocation:grep -rn "todo!\|unimplemented!\|#\[allow(clippy::unwrap_used)\]\|#\[allow(clippy::expect_used)\]" /home/connor/docs/projects/news/cratesexpect zero matches;cargo clippy --workspace --all-targets -- -D warningsexit 0. Verdict: APPROVE only if zero stub/suppression matches and clippy is clean. - F3. Real manual QA
What to verify: the full stack actually runs end-to-end. Build the image, run it via
podman-composeagainst a scratch compose file in/tmp/opencode, hit/healthz,/metrics,/api/stories,/api/config,POST /api/feedback, and load the web UI in a real browser via/playwright, exercising the feedback-button flow and confirming a real ntfy publish attempt is made (pointNTFY_*env at a scratch/test ntfy topic or a mock, not the productionntfy.rcjohnstone.comtopic). Tool + invocation:curl -i http://localhost:3000/healthz,curl -i http://localhost:3000/metrics,/playwrightbrowser session against the served UI,cargo run -p news-cli -- replay <fixtures>for a deterministic end-to-end check. Verdict: APPROVE only if every endpoint responds correctly, the UI renders and the feedback flow completes, and the replay harness produces sane, deterministic output. - F4. Scope fidelity
What to verify: nothing beyond the plan's Scope IN was added (no extra dependencies not named in any todo, no extra API endpoints, no extra config keys), and nothing in Scope OUT was violated (re-check the 8 Scope-OUT bullets one by one against the actual code).
Tool + invocation:
cargo tree --workspace -e normaldiffed against the dependency names actually referenced by todos 1-21 (flag any unexplained addition); manual read ofCargo.tomlper crate. Verdict: APPROVE only if every dependency and endpoint traces back to a specific todo, and every Scope-OUT bullet is confirmed unviolated.
Commit strategy
One commit per todo (21 implementation commits + 1 per Dockerfile/deploy-snippet todo already counted), using Conventional Commits as shown in each todo's Commit: line (feat(<crate>): <summary>, chore(workspace): ..., docs(deploy): ...). No squashing across todos — the history should read as the dependency order in the matrix above. Final wave produces no commits (verification only); if F1-F4 require fixes, those land as additional fix(<scope>): <summary> commits before re-running the failing reviewer.
Success criteria
cargo build --workspace,cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings, andcargo test --workspaceall exit 0.cd crates/news-web && trunk build --releaseexits 0 and produces a WASM bundle under 1.8MB.podman build -t news-triage:test /home/connor/docs/projects/newsexits 0 and the resulting image serves/healthz-> 200 and/metrics-> Prometheus text format.- The replay harness (
news-cli replay <fixtures>) and--dry-runflag both run deterministically without ever calling ntfy. - All 21 implementation todos and all 4 final-verification-wave reviewers are checked/APPROVE.
- Zero edits to
~/compose.yml,~/Caddyfile,~/data/ntfy/etc/server.yml, or~/config/prometheus/prometheus.yml; all four have a corresponding operator-applied snippet indeploy/README.md. - No LLM, GPU, or paid-API dependency anywhere in the codebase.