# Stream Q: quality and verification
> **Status 10 Sep:** Q1 to Q4 landed (audit, migration #2, verify, QUALITY.md; handoffs 5 Sep); Q5 is the 21 Sep human review. The body below is the brief as asked on 4 Sep.
**Written 2026-09-04 (Fri) against build plan v3 §3a and Decision 10.** Starts Sat 6 or Sun 7,
after Stream A's first pass has landed `read_one()` (check `git log --oneline | grep "^.* A:"`
and `.logs/handoff.md`); `audit` can start before that because it touches no collector.

## Goal
A repeatable, honest answer to "is this data true?", stored, and a bar the sixteen must clear
before they go public. Rian's first priority: no invented data; frequent sample checks against
real pages; occasional full human reviews. **Say plainly what verify measures**: freshness and
parser drift. It re-reads through the same parser, so it cannot catch a parser reading the
wrong field; that is guarded by ingest's distribution checks and the dated human review.

## Tasks, in order; ids are on /plan
1. **Q1 `app.cli audit`**, no network, one command, JSON snapshot to `.logs/verification/`
   until its table exists: vertical-vs-category mismatches; location currency vs observation
   currency; orphan `-P` tiles without a `::size` sibling; duplicate match_key groups by gtin
   kind; drinks with price and no size; size >5,000 ml outside multipacks; Extime capacity vs
   `price_per_quantity`; same-day price flips without `was_price`; cross-shop latest ratio
   >2.5; `in_stock=true` rate per source (100% is a smell); fallback-FX use; image provenance
   split; uncategorised per source; stuck runs older than 12 h; observation age per visible
   location vs the UI's 3-day threshold. Thresholds start at today's measured values and are
   stored beside the metric so tightening is a diff. Emit the lists the human review needs
   (brand folds, ratios, largest discounts, sample rows with retailer URLs).
2. **Q2 Migration #2** (schema-only; you take the migration token after A's #1 is deployed):
   `verification_runs(id, started_at, finished_at, seed, n, per_source JSONB)`;
   `verification_checks(id, run_id, listing_id, observation_id, live_price, live_currency,
   live_size_ml, live_name, live_gtin, live_in_stock, live_was_price, verdict, detail,
   checked_at, url)`; `audit_snapshots(id, taken_at, metrics JSONB)`; `rejected_observations(id,
   stage collector|ingest, reason, source_sku, url, payload JSONB, run_id, created_at)`; split
   `collection_runs.skipped_no_price` into per-reason counters. Then ingest's quarantine and veto
   points write rows instead of logging, and A's collector-layer hook writes too.
3. **Q3 `app.cli verify`** against A's `read_one()`: per source, **one process per host**, N=20
   after a collection (run the **next morning**, not immediately after), N=200 weekly across
   visible sources, on demand before any client-facing figure. Robots re-checked per host
   through `collectors/robots.py`; crawl-delay honoured; **abort the source at the first
   `SourceBlocked`** and record one BLOCKED check; never verify a source whose latest run was
   blocked. Verdicts: `PASS`, `PRICE_MOVED`, `MISMATCH_SIZE` (reuse `_sizes_disagree`),
   `MISMATCH_IDENTITY` (name-token overlap <0.75 or gtin differs), `MISMATCH_CURRENCY`, `GONE`,
   `PARSE_FAIL`, `BLOCKED`, and the no-network `REVIEW` (re-read price under half or over 2.5×
   the cross-shop median, or a `PRICE_MOVED` over 50%). Where a parser has two channels
   (Extime JSON-LD offer vs RSC variation price) compare them. **Sampling v1 is hardcoded**:
   census of every listing behind the unscoped featured eight and the top-20 savings (cheapest
   and dearest), plus every row moved >20%, then uniform random to fill N. **The tripwire rule
   (Decision 10):** any `MISMATCH_*` or `PARSE_FAIL` marks the source "publication blocked" until
   a human clears it; `PRICE_MOVED` and `GONE` are reported. The rolling per-source pass rate is
   a reporting number, never a launch gate at n=20. `PRICE_MOVED` writes a new observation with
   `source_kind='verify'` (assumption pending §10 #11). Render-cost sources (Changi) are
   verified only on the targeted set, via A2's `read_one`, capped at 20 per run.
4. **Q4 `main/docs/QUALITY.md`** (Docs creates the shell; you fill it): what audit and verify
   do and do not prove, the verdicts in plain language, the tripwire, the BLOCKED rule, the
   public labels ("Seen <date>" per row exists today; "Spot-checked <date>" per source only
   after a zero-correctness-failure run; never a per-row "Verified"), and the first real pass
   rates beside them once they exist. Rian said he understands this decision less than
   average; write it for him.
5. **Q5** is the Mon 21 Sep human review; prepare its checklist file from audit's lists.

## Owns
`app/services/verify.py`, `app/services/audit.py`, `app/cli_quality.py` (register its
subparsers via one `cli.py` line you request from A, or add after A hands back), migration #2,
`main/docs/QUALITY.md`, `.logs/verification/`, their tests (pure logic, no network; the
comparator gets the ATH Lancôme and the Glenfiddich 7 L cases as fixtures).

## Must not touch
Collectors and `ingest.py` beyond the write calls at the quarantine points (coordinate the
diff with A in `.logs/decisions-for-rian.md` "Requests"); `web/`; `seo.py`.

## Rules
- A verification request is a network request to a retailer: honest UA, robots, delay, stop
  on refusal. Twenty declared-bot requests into an Akamai 403 is exactly what this project
  says it does not do; hence abort-at-first-BLOCKED.
- Store, do not log: every decision that removes or refuses data lands in
  `rejected_observations` so the sampler can see it.
- No client-facing number is quoted without a verify run or a dated human check behind it.
