Files
news/.omo/plans/news-triage.md
T
connor 20a6c26f7c
Check / check (push) Failing after 1m27s
Check / guardrails (push) Failing after 39s
Check / bundle (push) Successful in 1m4s
Initial commit -- did OMO do a good job?
2026-09-01 18:29:46 -04:00

293 lines
58 KiB
Markdown

# news-triage - Work Plan
## TL;DR (For humans)
<!-- Fill this LAST, after the detailed plan below is written, so it summarizes the REAL plan. -->
<!-- Plain English for a non-engineer: NO file paths, NO todo numbers, NO wave/agent/tool names. -->
**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.toml` pinned.
- 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 access` grant 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-run` server 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 `dist` extracted 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.yml` service block, `~/Caddyfile` LAN-only vhost, ntfy ACL grant, `~/config/prometheus/prometheus.yml` scrape 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 `dist` out 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_assertions` for unit/integration tests in `news-core`/`news-ingest`/`news-store`/`news-server`; `wasm-bindgen-test` or Trunk-driven browser check for `news-web` where a DOM assertion is needed; `sqlite3` CLI and `curl` for manual/agent-executed QA against the running container.
- Evidence: `.omo/evidence/` + `<attemptDir>/task-<N>-news-triage.<ext>` (attemptDir = currentAttemptDir from `omo 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-core` domain 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.
<!-- APPEND TASK BATCHES BELOW THIS LINE WITH edit/apply_patch - never rewrite the headers above. -->
- [x] 1. Scaffold Cargo workspace, lints, rust-toolchain.toml, and CI skeleton
What to do: Create `/home/connor/docs/projects/news/Cargo.toml` as a workspace with `resolver = "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 minimal `Cargo.toml` + `src/lib.rs` (or `main.rs` for news-server/news-cli) that compiles. Add `rust-toolchain.toml` at repo root pinned to the stable channel (`rustc --version` on this box to pick the exact version) with `targets = ["wasm32-unknown-unknown"]`. Create `.gitea/workflows/ci.yml` with jobs `check` (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.md` lines 27 (crate list + responsibilities), 32-33 (CI/toolchain decisions).
Acceptance criteria (agent-executable): `cd /home/connor/docs/projects/news && cargo build --workspace` exits 0; `cargo fmt --all --check` exits 0; `cargo clippy --workspace --all-targets -- -D warnings` exits 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 confirm `cargo build --workspace` fails with "package not found", then restore, evidence same file.
Commit: Y | chore(workspace): scaffold Cargo workspace, lints, toolchain pin, CI skeleton
- [x] 2. news-core: domain types (Story, Source, Cluster, ScoreBreakdown, Feedback)
What to do: In `crates/news-core/src/model.rs`, define pure `serde`-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 }`. Add `mod model;` to `crates/news-core/src/lib.rs` and re-export. Must NOT do: no database types (no `sqlx::FromRow` here — 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.md` lines 15-22 (Components C1-C8, defines Story/Cluster/Score/Feedback concepts), line 43 (Sources v1), line 46 (op-ed flag `kind=opinion`).
Acceptance criteria: `cargo test -p news-core` exits 0 with at least one test asserting `Source`, `RawItem`, `StoryCluster`, `ScoreBreakdown`, `Feedback` round-trip through `serde_json::to_string`/`from_str` unchanged (property: `assert_eq!(deserialized, original)`).
QA scenarios: happy - `cargo test -p news-core model::tests::round_trip` passes, evidence `.omo/evidence/task-2-news-triage.txt` (paste test output); failure - construct a `RawItem` with `kind: ItemKind::Opinion` and 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
- [x] 3. news-core: importance scoring function (prominence + trust + corroboration)
What to do: In `crates/news-core/src/scoring/importance.rs`, implement `pub fn importance_score(prominence_rank: u32, source_trust_weight: f64, corroboration_count: u32, corroboration_window_minutes: u32) -> f64` combining: prominence (inverse of feed position, e.g. `1.0 / (1.0 + prominence_rank as f64)`), source trust (the `Source.weight` from todo 2), and corroboration (monotonic increasing in `corroboration_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 (no `HashMap` iteration order affecting the result; no time-of-day dependence inside this pure function — `corroboration_window_minutes` is 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.md` line 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::importance` exits 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 -- --exact` passes; failure - `cargo test -p news-core importance_is_deterministic -- --exact` (calls the function twice with identical inputs, asserts `assert_eq!(a, b)`) passes. Evidence `.omo/evidence/task-3-news-triage.txt`.
Commit: Y | feat(news-core): add importance scoring with corroboration saturation
- [x] 4. news-core: relevance scoring interface + Naive Bayes model with cold-start fallback
What to do: In `crates/news-core/src/scoring/relevance.rs`, define `pub trait Bm25Source { fn bm25_rank(&self, item_id: Uuid) -> Option<f64>; }` (implemented later by news-store against FTS5's `bm25()`), and `pub struct NaiveBayesModel { feature_counts: HashMap<String, (u32,u32)>, /* (interested, not_interested) */ total_interested: u32, total_not_interested: u32 }` with `pub fn train(&mut self, tokens: &[String], feedback: FeedbackKind)` and `pub fn score(&self, tokens: &[String]) -> f64` returning a log-odds relevance contribution. Implement `pub fn relevance_score(bm25: f64, bayes: &NaiveBayesModel, tokens: &[String]) -> f64` that returns `bm25` alone (Bayes term contributes exactly 0.0) whenever `bayes.total_interested + bayes.total_not_interested < 20`, and `bm25 + 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 on `news-store` or `sqlx` in this crate.
Parallelization: Wave 1 | Blocked by: 2 | Blocks: 10
References: `.omo/drafts/news-triage.md` line 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::relevance` exits 0 including a test named exactly asserting the 19-vs-20-sample boundary (`cold_start_boundary_at_20_samples` or equivalent name containing "cold_start").
QA scenarios: happy - with 20 trained samples, `relevance_score` output differs from `bm25` input 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.0` exactly (bit-identical to bm25 input), evidence same file.
Commit: Y | feat(news-core): add BM25+NaiveBayes relevance scoring with cold-start fallback
- [x] 5. news-store: SQLite schema/migrations including FTS5 virtual table
What to do: In `crates/news-store/migrations/`, add sqlx migration files (`0001_init.sql` etc.) 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 table `raw_items_fts USING fts5(title, summary, content='raw_items', content_rowid='rowid', tokenize='porter')` with triggers to keep it synced on insert/update/delete. In `crates/news-store/src/lib.rs`, call `sqlx::migrate!("./migrations")` from a `pub async fn init_db(url: &str) -> Result<SqlitePool, StoreError>` that also runs `PRAGMA journal_mode=WAL;`. Must NOT do: no hand-written migration runner; no raw `CREATE TABLE IF NOT EXISTS` outside 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 via `sqlx::migrate!`, run at startup, no entrypoint script), `https://sqlite.org/fts5.html` section 5.1.1 (`bm25()`, `content=` external-content tables, porter tokenizer), `.omo/drafts/news-triage.md` line 17 (C3-score evidence), line 46 (data retention).
Acceptance criteria: `cargo test -p news-store init_db_runs_migrations` passes against an in-memory `sqlite::memory:` pool, asserting `sqlx::query("SELECT name FROM sqlite_master WHERE type='table'")` includes all 7 named tables plus `raw_items_fts`.
QA scenarios: happy - insert a row into `raw_items`, then `SELECT 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
- [x] 6. news-store: source registry repository (CRUD, weight/enable/poll-interval)
What to do: In `crates/news-store/src/sources.rs`, implement `pub struct SourceRepo(SqlitePool)` with `async 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 Jazeera `https://www.aljazeera.com/xml/rss/all.xml` kind=RssAtom poll=300s, BBC World `https://feeds.bbci.co.uk/news/world/rss.xml` kind=RssAtom poll=900s honoring its declared ttl 15, AP news-sitemap `https://apnews.com/news-sitemap-content.xml` kind=NewsSitemap poll=300s) via a seed migration or a `seed_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.md` line 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_idempotent` passes: calling it twice results in exactly 3 rows in `sources`, not 6.
QA scenarios: happy - `SourceRepo::list_enabled` returns exactly 3 sources with the exact URLs above, evidence `.omo/evidence/task-6-news-triage.txt`; failure - `set_enabled(bbc_id, false)` then `list_enabled` returns 2, evidence same file.
Commit: Y | feat(news-store): add source registry repository with v1 seed sources
- [x] 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`, implement `pub struct FeedPoller { client: reqwest::Client }` with `pub async fn poll(&self, source: &Source, etag: Option<&str>, last_modified: Option<&str>) -> Result<PollResult, IngestError>` where `PollResult` is one of `NotModified`, `Items(Vec<RawItem>, new_etag: Option<String>, new_last_modified: Option<String>)`, `RateLimited(retry_after: Option<Duration>)`. Send `If-None-Match`/`If-Modified-Since` headers when provided; parse RSS/Atom via `quick-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. Set `User-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, return `RateLimited` with `Retry-After` parsed if present, exponential backoff computed by the caller (news-server scheduler, todo 18). Must NOT do: no following of `Disallow`-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: AJ `all.xml` -> 200, BBC world rss -> 200 ttl 15, AP `index.rss` -> 401, AP `robots.txt` -> `Disallow: /*.rss`, `Disallow: /api/v2/feed/`, advertises `Sitemap: https://apnews.com/news-sitemap-content.xml` -> verified 200 with `news:title`+`news:publication_date`. `.omo/drafts/news-triage.md` lines 43-44.
Acceptance criteria: `cargo test -p news-ingest` exits 0 with fixture-based tests (saved XML snapshots in `crates/news-ingest/tests/fixtures/`) for: RSS parse extracts correct title/link/pubDate, news-sitemap parse extracts correct title/publication_date, a 304 response maps to `PollResult::NotModified`, a 429 with `Retry-After: 30` maps to `RateLimited(Some(Duration::from_secs(30)))`.
QA scenarios: happy - `cargo test -p news-ingest parses_bbc_fixture` passes against a committed BBC RSS snapshot, evidence `.omo/evidence/task-7-news-triage.txt`; failure - `cargo test -p news-ingest rate_limited_on_429` passes, 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 -1` must show `200`, captured in the evidence file.
Commit: Y | feat(news-ingest): add RSS/Atom + news-sitemap poller with conditional GET and backoff
- [x] 8. news-store: story ingestion repository (insert raw item, populate FTS5)
What to do: In `crates/news-store/src/items.rs`, implement `pub struct ItemRepo(SqlitePool)` with `async fn insert_if_new(&self, item: &RawItem) -> Result<InsertOutcome, StoreError>` (dedupes on `(source_id, link)` unique constraint from todo 5's schema; `InsertOutcome::Inserted` or `AlreadyExists`), and `async fn search_fts(&self, query: &str, limit: u32) -> Result<Vec<(RawItem, f64)>, StoreError>` returning `(item, bm25_score)` via `SELECT ..., bm25(raw_items_fts) AS rank FROM raw_items_fts WHERE raw_items_fts MATCH ?1 ORDER BY rank LIMIT ?2` (SQLite's `bm25()` 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 rank` ascending = best match first), `.omo/drafts/news-triage.md` line 17.
Acceptance criteria: `cargo test -p news-store items::insert_if_new_dedupes_on_source_and_link` passes: inserting the same `(source_id, link)` twice returns `AlreadyExists` on 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
- [x] 9. news-core + news-store: clustering + corroboration counting
What to do: In `crates/news-core/src/clustering.rs`, implement a pure `pub fn similarity(a: &RawItem, b: &RawItem) -> f64` (title token-overlap / Jaccard, since no LLM embeddings are allowed) and `pub fn should_cluster(sim: f64, threshold: f64) -> bool`. In `crates/news-store/src/clusters.rs`, implement `pub struct ClusterRepo(SqlitePool)` with `async 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 exceeds `threshold`, adds the item as a member and increments `source_count` if the new item's `source_id` is 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.md` line 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_count` passes: two items from the SAME source clustered together yield `source_count == 1`; two items from DIFFERENT sources clustered together yield `source_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's `last_seen_at` starts 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
- [x] 10. news-server: relevance scorer wiring (BM25 via FTS5 + Naive Bayes)
What to do: In `crates/news-server/src/scoring.rs`, implement `pub struct RelevanceScorer { item_repo: ItemRepo, bayes: NaiveBayesModel }` wrapping `news_core::scoring::relevance::relevance_score`, loading/saving the `NaiveBayesModel` to/from the `bayes_model` table (todo 5's schema) on each score/train call. Wire `news_store::ItemRepo::search_fts`'s bm25 rank (negate: SQLite's `bm25()` is ascending-better, so use `-rank` or `1.0/(1.0+rank)` to get an ascending-worse-to-better score consistent with `importance_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 relevance `f64::NEG_INFINITY` (never notify-eligible) regardless of BM25/Bayes.
Parallelization: Wave 3 | Blocked by: 4,8 | Blocks: 12,13
References: `.omo/drafts/news-triage.md` line 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_infinity` passes: an item whose title contains a blocklisted term scores `f64::NEG_INFINITY` regardless 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 is `NEG_INFINITY` even 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
- [x] 11. news-server: importance scorer wiring + trailing-7-day percentile stats tracker
What to do: In `crates/news-server/src/scoring.rs` (extend), implement `pub struct ImportanceScorer` wrapping `news_core::scoring::importance::importance_score`, fed by `ClusterRepo`'s `source_count` and each source's `weight`. In `crates/news-store/src/percentile.rs`, implement `pub struct PercentileTracker(SqlitePool)` with `async fn record(&self, metric_kind: MetricKind, value: f64) -> Result<(), StoreError>` (appends to `percentile_stats`) and `async fn percentile(&self, metric_kind: MetricKind, p: f64, window_days: u32) -> Result<f64, StoreError>` computing the Nth percentile over rows within the trailing window via `SELECT value FROM percentile_stats WHERE metric_kind = ?1 AND computed_at >= ?2 ORDER BY value` and interpolating at index `ceil(p/100 * count) - 1`. Two `MetricKind` variants: `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.md` line 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_distributions` passes: recording 10 importance values and 10 different relevance values, then asserting `percentile(Importance, 90.0, 7)` and `percentile(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 explicit `Err(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
- [x] 12. news-server: notification gate (token bucket, quiet hours, adaptive threshold, breaking bypass, suppression)
What to do: In `crates/news-server/src/gate.rs`, implement `pub struct NotificationGate { budget: TokenBucket, config: NotifyConfig }` with `pub async fn evaluate(&mut self, cluster: &StoryCluster, score: &ScoreBreakdown, now: DateTime<Tz>) -> GateDecision` where `GateDecision` is one of `Notify`, `Suppress(reason: SuppressReason)`, `Digest` (queued for C7). Logic, in order: (1) if this cluster already notified (check `notification_log`), `Suppress(AlreadyNotified)` unless a material-update rule fires (defined as: `source_count` increased by >= 2 since last notify) — then allow a second `Notify`; (2) breaking bypass check FIRST — if `score.importance_percentile >= 99.0` OR 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 own `notification_log` tag) — if under that ceiling, `Notify` immediately, bypassing quiet hours AND the normal token bucket; if the bypass ceiling itself is exhausted, fall through to normal path; (3) normal path — if `now` is within quiet hours (22:00-07:00 America/Louisville) or `score.relevance_percentile < 90.0`, `Suppress` or `Digest` (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 and `Notify`. 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.md` line 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_budget` passes: exhaust the normal token bucket to 0, then feed 5 breaking-tier stories in one 24h window — assert exactly 4 return `Notify` (bypass ceiling) and the 5th returns `Suppress`, with the normal bucket's token count unaffected throughout.
QA scenarios: happy - a story at relevance_percentile 95 outside quiet hours with tokens available returns `Notify`, evidence `.omo/evidence/task-12-news-triage.txt`; failure - a story at relevance_percentile 80 (digest band) returns `Digest`, not `Notify` or full `Suppress`, evidence same file; also test the material-update case: same cluster notified once, then source_count grows by 3, second `evaluate` call returns `Notify` again.
Commit: Y | feat(news-server): implement notification gate with independent bypass ceiling
- [x] 13. news-server: feedback capture endpoint + Naive Bayes retrain hook
What to do: In `crates/news-server/src/api/feedback.rs`, implement `POST /api/feedback` (axum handler) accepting `{ "story_cluster_id": "<uuid>", "kind": "interested" | "not_interested" }`, persisting via a `FeedbackRepo` (news-store, mirrors todo 6/8's repo pattern) and calling `RelevanceScorer::train` (todo 10) with the cluster's tokens, updating the persisted `NaiveBayesModel` row. Return `200 {"status":"recorded"}` on success, `400` with an error body on an unknown `story_cluster_id`, `422` on a malformed `kind`. Must NOT do: no synchronous full model retrain from scratch on every feedback call — incremental `train()` (single-item update) only, per news-core's `NaiveBayesModel::train` signature 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.md` line 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_400` and `api::feedback::malformed_kind_returns_422` both pass via `axum::body` + `tower::ServiceExt::oneshot` in-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
- [x] 14. news-server: config loader with hot-reload on SIGHUP
What to do: In `crates/news-server/src/config.rs`, define `NewsConfig` (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) via `toml::from_str`. Implement `pub async fn watch_sighup(config: Arc<RwLock<NewsConfig>>, path: PathBuf)` using `tokio::signal::unix::signal(SignalKind::hangup())` to re-parse and swap the config on SIGHUP, logging (via `tracing`) 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-memory `NewsConfig` or 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.md` line 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_config` passes: 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 via `tracing_test` or a custom subscriber).
QA scenarios: happy - valid config with `notify_opinions = true` loads and `config.notify.notify_opinions == true`, evidence `.omo/evidence/task-14-news-triage.txt`; failure - malformed TOML (`[notify` missing 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
- [x] 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`, implement `pub struct NtfyPublisher { base_url: Url, topic: String, auth: BasicAuth }` with `pub async fn publish(&self, story: &PublishableStory) -> Result<(), NotifyError>` sending a `POST` to `{base_url}/{topic}` with headers `Title`, `Priority` (mapped: breaking-bypass Notify -> 5, normal Notify -> 3), `Tags`, `Markdown: yes`, `Click` (article link), and `Actions` (view-button to the article, http-buttons to `POST /api/feedback` for Interested/NotInterested — mirrors `movie_recs_notify`'s header shape). Read `NTFY_TOKEN`/`NTFY_USER`/`NTFY_PASS` from env (per the `${VAR}`-from-compose secrets decision). Create `deploy/ntfy-access-snippet.md` documenting the exact command: `ntfy access <news-user> news-triage rw` (or the equivalent for the confirmed `auth-default-access: deny-all` posture) 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.md` line 38 (ntfy ACL provisioning decision).
Acceptance criteria: `cargo test -p news-server notify::ntfy::request_has_required_headers` passes using a `wiremock` (or `httptest`) mock server asserting the outgoing request has `Title`, `Priority`, `Markdown: yes`, `Click`, and at least 2 `Actions` entries.
QA scenarios: happy - against the mock server, a breaking-bypass `Notify` sets `Priority: 5`; a normal `Notify` sets `Priority: 3`, evidence `.omo/evidence/task-15-news-triage.txt`; failure - a publish that gets a 500 from the mock server retries exactly once then returns `Err` without panicking, evidence same file. Also produce `deploy/ntfy-access-snippet.md` and paste its content into the evidence file.
Commit: Y | feat(news-server): add ntfy publisher with action buttons and ACL operator snippet
- [x] 16. news-server: daily digest (C7)
What to do: In `crates/news-server/src/notify/digest.rs`, implement `pub async fn build_and_send_digest(item_repo: &ItemRepo, ntfy: &NtfyPublisher, config: &NewsConfig, now: DateTime<Tz>) -> Result<(), NotifyError>`, gated to run once when `now`'s local time crosses 07:00 America/Louisville (track "already sent today" via a `notification_log` row tagged `digest`), querying stories from the prior 24h whose `relevance_percentile` fell between 75.0 and 90.0 (the `Digest` `GateDecision` from todo 12), excluding opinion pieces unless `notify_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.md` line 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_stories` passes: with zero stories in the 75-90 percentile band, `build_and_send_digest` returns `Ok(())` 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 - calling `build_and_send_digest` twice within the same day sends only once (idempotency via the `notification_log` "digest" tag check), evidence same file.
Commit: Y | feat(news-server): add daily digest for sub-threshold-but-interesting stories
- [x] 17. news-cli: replay harness + --dry-run server flag (C8)
What to do: In `crates/news-cli/src/main.rs` (clap-derived), implement subcommand `replay <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. In `crates/news-server/src/main.rs`, add a `--dry-run` CLI flag (clap) that runs the full live poll loop against real feeds but routes every `GateDecision::Notify`/`Digest` through the same stdout-printing path instead of `NtfyPublisher::publish`. Must NOT do: `--dry-run` must not write to `notification_log` as if a real notification happened — it logs the decision but the row is tagged `dry_run=true` so 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.md` line 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 a `GateDecision` variant 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 fixed `now`), evidence `.omo/evidence/task-17-news-triage.txt`; failure - `--dry-run` against real feeds for one poll cycle produces console output and a `dry_run=true` notification_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
- [x] 18. news-server: axum wiring (/healthz, /metrics, scheduler, main.rs)
What to do: In `crates/news-server/src/main.rs`, wire: `GET /healthz` returning `200 {"status":"ok","db":"ok"}` after a `SELECT 1` round-trip against the SQLite pool (500 with `{"status":"error","db":"<message>"}` if the round-trip fails); `GET /metrics` returning Prometheus text format via a `prometheus` crate `Registry` exposing at minimum: `news_poll_total{source}`, `news_notify_total{lane="normal"|"bypass"|"digest"}`, `news_suppress_total{reason}`, `news_scrape_duration_seconds`. Scheduler: a `tokio::time::interval`-driven loop per enabled source respecting its `poll_interval_secs` (todo 6), calling `FeedPoller::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 deleting `raw_items` older than 90 days (keeping `story_clusters`/`feedback`/`bayes_model` indefinitely). On a `FeedPoller` `RateLimited` result, 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.md` line 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_reachable` and `main::pruning_job_deletes_items_older_than_90_days_keeps_newer` both pass (in-process request test for healthz; direct repo call + assertion for pruning).
QA scenarios: happy - `curl -s http://localhost:3000/healthz` against 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_total` shows 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
- [x] 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, matching `runway-web`'s toolchain) with: a story list view calling `GET /api/stories?since=<ts>` (add this read-only endpoint alongside todo 13's feedback endpoint in news-server) showing title, score, and `GateDecision`/suppression reason per story; feedback buttons calling `POST /api/feedback` (todo 13); a read-only config view calling `GET /api/config` (add this endpoint too) rendering the current `NewsConfig` (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.md` line 81 (Scope IN: tiny local Leptos web UI for reviewing stories, suppression reasons, feedback).
Acceptance criteria: `cd crates/news-web && trunk build --release` exits 0 and produces a `dist/` directory containing a `.wasm` file.
QA scenarios: happy - `/playwright` skill: `trunk serve` the 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/feedback` with 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/stories` returning 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
- [x] 20. Dockerfile (one image, dist-lift build) + finalize CI bundle job
What to do: Write `/home/connor/docs/projects/news/Dockerfile` mirroring `runway/Dockerfile`'s structure exactly: a `web` build 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 --release` producing `crates/news-web/dist`), a `server` build stage (`cargo build --release --locked -p news-server`), and a `runtime` stage (debian:bookworm-slim, `ca-certificates`+`tzdata`, copies the server binary AND `COPY --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`'s `bundle` job (stubbed in todo 1) to fully match `runway/.gitea/workflows/ci.yml:86-121`: install Trunk, `npm ci`, `trunk build --release` in `crates/news-web`, then assert the produced `.wasm` file 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.md` line 50 (WASM bundle budget decision).
Acceptance criteria: `podman build -t news-triage:test /home/connor/docs/projects/news` exits 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/dist` shows an `index.html` and at least one `.wasm` file, evidence `.omo/evidence/task-20-news-triage.txt`; failure - temporarily inflate a dependency to push the WASM bundle over 1.8MB and confirm the `bundle` CI 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
- [x] 21. Operator snippets: compose service, Caddy LAN vhost, ntfy ACL, Prometheus scrape, deploy timer
What to do: Create `deploy/README.md` (mirroring `runway/deploy/README.md`'s structure) documenting, as copy-pasteable snippets the OPERATOR applies (never auto-applied by the implementer): (1) a `news-backend` service block for `~/compose.yml` — image `git.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) a `news.rcjohnstone.com` Caddyfile block matching the LAN-only pattern (internal CA, no `# ddns: public` marker, `reverse_proxy news-backend:3000` for `/api/*`, static file serving with `root * /srv/news/dist` + `index.html` fallback for everything else, mounting `./data/news/web:/srv/news:ro` — mirroring the "mount the holder not dist itself" atomic-rename lesson); (3) the exact `ntfy access` grant command for the confirmed `deny-all` default; (4) a Prometheus scrape-config snippet for `./config/prometheus/prometheus.yml` (`job_name: news-triage`, target `news-backend:3000`, path `/metrics`); (5) a `deploy/news-update` script + systemd **system** timer unit pair (mirroring `runway/deploy/runway-update.service`/`.timer` exactly: root timer, pulls the image, extracts `dist` to `~/data/news/web` via 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.md` lines 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.md` all pass (a lightweight structural check that every required snippet section is present).
QA scenarios: happy - a human (simulated by an agent) follows `deploy/README.md` step-by-step against a scratch copy of `~/compose.yml`/`~/Caddyfile` in `/tmp/opencode` and confirms the pasted snippets produce valid YAML/Caddyfile syntax (`podman-compose -f /tmp/opencode/compose.yml config` and `caddy validate --config /tmp/opencode/Caddyfile` both 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 via `stat` timestamps 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 serving `dist`), and every Scope-IN item from the plan's `## Scope` section has a corresponding implemented component.
Tool + invocation: `grep -riE "liteLLM|llama.cpp|gpt-oss|gemma3" /home/connor/docs/projects/news/crates` expect zero matches; diff-style check that the 4 protected files' mtimes are unchanged from before implementation started; read `.omo/plans/news-triage.md` and 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, no `unwrap()`/`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/crates` expect zero matches; `cargo clippy --workspace --all-targets -- -D warnings` exit 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-compose` against 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 (point `NTFY_*` env at a scratch/test ntfy topic or a mock, not the production `ntfy.rcjohnstone.com` topic).
Tool + invocation: `curl -i http://localhost:3000/healthz`, `curl -i http://localhost:3000/metrics`, `/playwright` browser 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 normal` diffed against the dependency names actually referenced by todos 1-21 (flag any unexplained addition); manual read of `Cargo.toml` per 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`, and `cargo test --workspace` all exit 0.
- `cd crates/news-web && trunk build --release` exits 0 and produces a WASM bundle under 1.8MB.
- `podman build -t news-triage:test /home/connor/docs/projects/news` exits 0 and the resulting image serves `/healthz` -> 200 and `/metrics` -> Prometheus text format.
- The replay harness (`news-cli replay <fixtures>`) and `--dry-run` flag 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 in `deploy/README.md`.
- No LLM, GPU, or paid-API dependency anywhere in the codebase.