F4 Scope-Fidelity Review — news-triage Reviewer: Sisyphus-Junior (F4) Date: 2026-09-01 Project: /home/connor/docs/projects/news Standard: Every direct dependency, route, and config key must trace to a specific plan todo; every Scope-OUT bullet must be confirmed unviolated. ================================================================================ FINAL VERDICT: APPROVE ================================================================================ All required traces succeed and all eight Scope-OUT bullets are unviolated. No unexplained direct dependency, no extra route, and no extra config key was found. Minor accepted deviations (NEWS_DATABASE_URL env support, root package.json, --once dry-run helper) are already recorded in the project notepads/ledger and are not flagged. ================================================================================ CHECK 1 — DIRECT-DEPENDENCY TRACE ================================================================================ Command: $ cargo tree --workspace -e normal --depth 1 Output (direct dependencies only): news-cli v0.1.0 ├── chrono v0.4.45 ├── chrono-tz v0.10.4 ├── clap v4.6.6 ├── news-core v0.1.0 ├── news-ingest v0.1.0 ├── news-server v0.1.0 ├── news-store v0.1.0 ├── serde v1.0.229 ├── sqlx v0.8.6 ├── thiserror v2.0.20 ├── tokio v1.53.1 ├── toml v0.8.23 └── uuid v1.26.0 news-core v0.1.0 (no extra direct deps beyond workspace deps) news-ingest v0.1.0 (no extra direct deps beyond workspace/internal deps) [dev-dependencies: wiremock] news-server v0.1.0 (no extra direct deps beyond workspace/internal deps) [dev-dependencies: tower, tracing-test, tempfile, wiremock] news-store v0.1.0 (no extra direct deps beyond workspace/internal deps) news-web v0.1.0 ├── console_error_panic_hook v0.1.7 ├── gloo-net v0.6.0 ├── leptos v0.8.20 ├── serde v1.0.229 └── serde_json v1.0.151 Workspace / root direct dependencies: Internal: news-core, news-store, news-ingest, news-server serde, thiserror, uuid, chrono, chrono-tz, serde_json, pretty_assertions, sqlx, tokio, reqwest, url, quick-xml, toml, tracing, tracing-subscriber, clap, tracing-test, tempfile, axum, tower, prometheus Root package.json devDependencies: @playwright/test, @tailwindcss/cli, tailwindcss -------------------------------------------------------------------------------- Traceability table — every direct dependency maps to a todo -------------------------------------------------------------------------------- Dependency Justifying todo / evidence -------------------------- ---------------------------------------------------- serde Todo 1 (workspace skeleton, serde derive), Todo 2 (model structs), Todo 14 (NewsConfig). Used in every crate. thiserror Todo 1 (workspace skeleton). Error types in all crates. uuid Todo 2 (domain ids). Used in news-core, news-store, news-ingest, news-server, news-cli. chrono Todo 2 (timestamps). Used throughout. chrono-tz Todo 14/16 (quiet hours America/Louisville, digest 07:00 Louisville). Also news-cli replay uses the gate (todo 17). Recorded as accepted deviation. serde_json Todo 2 (round-trip tests), Todo 13 (feedback JSON), Todo 19 (frontend JSON). Also workspace tests. pretty_assertions Todo 1 ("JSON round-trip assertions + readable diffs"). sqlx Todo 5 (SQLite schema/migrations). Used in news-store, news-server, news-cli. tokio Todo 5 (async runtime), Todo 18 (scheduler/signal). reqwest Todo 7 (feed polling HTTP client), Todo 15 (ntfy publisher HTTP POST). url Todo 7 (reqwest), Todo 15 (NtfyPublisher base_url is a Url). quick-xml Todo 7 (RSS/Atom + news-sitemap parsing). toml Todo 14 (TOML config loader), Todo 17 (replay CLI may use config). Also news-cli. tracing Todo 14 (SIGHUP reload logging), Todo 18 (scheduler logging). tracing-subscriber Todo 18 (binary logging init). clap Todo 17 (--dry-run server flag, news-cli replay subcommand), Todo 18 (server CLI). tracing-test Todo 14 (capture tracing logs in tests). Dev-only. tempfile Todo 14 (temp config fixtures). Dev-only. axum Todo 13 (POST /api/feedback), Todo 18 (/healthz, /metrics), Todo 19a (/api/stories, /api/config). tower Todo 13 (tower::ServiceExt::oneshot in-process tests). Dev-only in news-server. prometheus Todo 18 (Prometheus text exposition for /metrics). console_error_panic_hook Todo 19 (Leptos 0.8 CSR app, matching runway-web toolchain; runway-web Cargo.toml also lists this dep). gloo-net Todo 19b (frontend fetch layer). Recorded as accepted deviation. leptos Todo 19 (Leptos 0.8 CSR web UI). @playwright/test Todo 19b (browser QA). Recorded as accepted deviation (root package.json). @tailwindcss/cli Todo 19b (Tailwind v4 build). Accepted deviation. tailwindcss Todo 19b (Tailwind v4). Accepted deviation. wiremock Todo 7 (feed polling fixtures), Todo 15 (ntfy publisher mock). Dev-only. Observed, not present: rusqlite, feed-rs, scraper, regex — these are NOT direct dependencies. (regex appears only transitively via leptos.) Check 1 result: PASS — every direct dependency traces to a todo; no unexplained direct dependency found. ================================================================================ CHECK 2 — ENDPOINT TRACE ================================================================================ All routes are registered in crates/news-server/src/main.rs: let app = axum::Router::new() .merge(news_server::api::feedback::router(rt.pool.clone())) .merge(news_server::api::stories::router( rt.pool.clone(), Arc::clone(&rt.gate), Arc::clone(&config), )) .merge(news_server::api::config::router(Arc::clone(&config))) .merge(news_server::health::router(rt.pool, rt.metrics)); Routers and their paths: feedback.rs: POST /api/feedback stories.rs: GET /api/stories config.rs: GET /api/config health.rs: GET /healthz health.rs: GET /metrics Trace to todos: /healthz → Todo 18 ("GET /healthz returning 200 {status:ok,db:ok}") /metrics → Todo 18 ("GET /metrics returning Prometheus text format") POST /api/feedback → Todo 13 ("POST /api/feedback ... persists feedback") GET /api/stories → Todo 19a ("add this read-only endpoint alongside todo 13's feedback endpoint") GET /api/config → Todo 19a ("add this endpoint too") No other .route(...) calls exist in news-server/src. No fallback or static-file route is mounted. Check 2 result: PASS — exactly the five routes implied by the plan are present; no extra route found. ================================================================================ CHECK 3 — CONFIG-KEY TRACE ================================================================================ NewsConfig fields (crates/news-server/src/config.rs): pub struct NewsConfig { pub sources: Vec, pub notify: NotifySection, pub topics: TopicsSection, } pub struct SourceConfig { pub url: String, pub kind: String, pub weight: f64, pub enabled: bool, pub poll_interval_secs: Option, } pub struct NotifySection { pub notify_opinions: bool, pub quiet_hours_start: String, pub quiet_hours_end: String, pub budget_refill_per_day: f64, pub budget_burst: f64, pub bypass_ceiling_per_day: u32, } pub struct TopicsSection { pub interests: Vec, pub blocklist: Vec, } Trace to plan/draft: sources[] → Todo 14/18 ("[[sources]] url/kind/weight/enabled/ poll_interval_secs override") SourceConfig.url → Todo 14 SourceConfig.kind → Todo 14 SourceConfig.weight → Todo 14 (source trust weight) SourceConfig.enabled → Todo 14 SourceConfig.poll_interval_secs → Todo 14 (optional override) notify.notify_opinions → Todo 14/16 (digest opinion filtering) notify.quiet_hours_start → Todo 14 notify.quiet_hours_end → Todo 14 notify.budget_refill_per_day → Todo 12/14 (token bucket 2/day) notify.budget_burst → Todo 12/14 (burst 3) notify.bypass_ceiling_per_day → Todo 12/14 (bypass cap 4/24h) topics.interests → Todo 14 topics.blocklist → Todo 10/14 (blocklist veto) Digest fields: there is NO dedicated [digest] config section. Digest time (07:00 America/Louisville) and percentile band (75th–90th) are hardcoded in crates/news-server/src/notify/digest.rs and crates/news-server/src/gate.rs, matching Todo 16. Dry-run: this is a CLI flag (--dry-run), not a NewsConfig key. Implemented in Todo 17 (crates/news-server/src/main.rs Cli struct). Check 3 result: PASS — every NewsConfig field traces to the plan; no extra config key found. ================================================================================ CHECK 4 — SCOPE-OUT RE-CHECK (8 bullets) ================================================================================ Bullet 1 — "No LLM, LiteLLM, llama.cpp, or GPU dependency anywhere in the pipeline." Evidence: grep -riE across crates/ for llm|litellm|llama\.cpp|gpt-oss|gemma3| openai|anthropic|claude|gemini|moonshot|kimi|deepseek|huggingface|transformers| ollama|vllm|tensorrt|cuda|gpu|nvidia returned ZERO matches. Result: UNVIOLATED. Bullet 2 — "No full-article scraping, paywall circumvention, or republishing article text; headline + feed summary + link only." Evidence: Ingest parsers only extract title, summary, link, published date, and category: - crates/news-ingest/src/rss_atom.rs fields: Title, Link, PublishedAt, Summary, Category. - crates/news-ingest/src/news_sitemap.rs fields: Loc, Title, PublicationDate; summary is explicitly empty string. grep for scraper|scrape|full.?article|paywall|bypass.*pay|article.?body| readability in crates/ returned only metric names ("scrape_duration_seconds", "poll" in scheduler/cycle.rs) — no article-scraping code. Result: UNVIOLATED. Bullet 3 — "No paid API, no hosted AI service, no new Postgres/Redis/Kafka/ Kubernetes." Evidence: grep for postgres|redis|kafka|kubernetes|k8s|mysql|mariadb|mongodb| dynamodb|bigquery|gcs|s3\.amazon|azure|openai.*api|paid.*api returned ZERO matches. Persistence is SQLite via sqlx (Todo 5). Notifications use self-hosted ntfy configured via env vars (Todo 15). Result: UNVIOLATED. Bullet 4 — "No public consumer-facing product, mobile app, new public DNS record, or Authelia gate — LAN/VPN-only posture, matching grafana/llama-swap." Evidence: deploy/README.md states: "TLS and auth | The root Caddy, on news.rcjohnstone.com, LAN/VPN-only behind the internal snippet" Caddyfile snippet uses `tls internal`, `import internal`, `import secure_headers`, `import accesslog`, and explicitly comments: "Internal CA and no # ddns: public marker. Publishing this name would put it in Certificate Transparency permanently and make a personal dashboard reachable from the internet." No mobile app source exists; the only UI is the Leptos CSR app served by Caddy, not by the backend. Result: UNVIOLATED. Bullet 5 — "No implementer edits to ~/compose.yml, ~/Caddyfile, ~/data/ntfy/etc/server.yml, or ~/config/prometheus/prometheus.yml — snippets only." Evidence: - grep for news-triage|news-backend|news.rcjohnstone.com in /home/connor/compose.yml returned ZERO matches. - grep for news-triage|news.rcjohnstone.com|news-backend|/srv/news in /home/connor/Caddyfile returned ZERO matches (the lone `path /api /api/*` match is inside runway.rcjohnstone.com, not news-triage). - grep for news-triage|news-backend in /home/connor/config/prometheus/prometheus.yml returned ZERO matches. - /home/connor/data/ntfy/etc/server.yml was not touched for news-triage configuration; the deploy README only instructs the operator to run an `ntfy access` command. - Note: /home/connor/compose.yml mtime is 2026-09-01, but its content contains no news-triage snippets, so no implementer edit for this project is present. Result: UNVIOLATED. Bullet 6 — "No unbounded breaking-news notifications — the bypass lane is capped independently of the normal budget." Evidence: crates/news-server/src/gate.rs: - NotifyConfig::default sets bypass_daily_ceiling: 4. - decide() checks bypass_count_last_24h() < bypass_daily_ceiling before emitting a bypass Notify. - bypass_count_last_24h queries `lane = 'bypass' AND dry_run = 0`. - TokenBucket is only decremented in the normal lane (`if lane == "normal"` then budget.tokens -= 1.0); the bypass lane never touches it. - Test `bypass_ceiling_is_independent_of_normal_budget` verifies exactly 4 bypass notifications with an exhausted normal bucket. Result: UNVIOLATED. Bullet 7 — "No serving the frontend SPA from the backend process — the image only lifts dist out for Caddy." Evidence: - crates/news-server/src/main.rs mounts only API/health routers; no static file server, no ServeDir, no fallback to index.html. - Dockerfile: "COPY --from=web /app/crates/news-web/dist /srv/dist" with comment: "Where the updater looks for the frontend. Nothing serves it from inside this container." - deploy/news-update copies /srv/dist out of the image onto the host at ~/data/news/web/dist for Caddy to serve. Result: UNVIOLATED. Bullet 8 — "No invented MVP/phase-1 reduction of any of the above." Evidence: All major Scope-IN items are present: - 6-crate workspace, rust-toolchain.toml, CI (Todo 1) - Domain model, scoring, clustering (Todos 2-4, 9-11) - SQLite + FTS5, source registry, feed polling (Todos 5-8) - Relevance/importance scorers, percentile tracker (Todos 10-11) - Notification gate with budget/quiet-hours/bypass/suppression (Todo 12) - Feedback endpoint + Bayes retrain (Todo 13) - SIGHUP config reload (Todo 14) - ntfy publisher + ACL snippet (Todo 15) - Daily digest (Todo 16) - Replay harness + --dry-run (Todo 17) - /healthz, /metrics, scheduler, pruning (Todo 18) - Leptos web UI + API read endpoints (Todo 19) - Dockerfile + CI bundle gate (Todo 20) - Operator snippets (Todo 21) No Scope-IN item was dropped or reduced. Result: UNVIOLATED. Check 4 result: PASS — all 8 Scope-OUT bullets confirmed unviolated. ================================================================================ SUMMARY ================================================================================ Check 1 (Dependency trace): PASS Check 2 (Endpoint trace): PASS Check 3 (Config-key trace): PASS Check 4 (Scope-OUT re-check): PASS No unexplained direct dependencies, no extra routes, no extra config keys, and no Scope-OUT violations were found. Accepted deviations recorded in .omo/notepads/news-triage/learnings.md and .omo/start-work/ledger.jsonl were not flagged. FINAL VERDICT: APPROVE