QA Sign-Off Checklist — California Legislature Daily File Scraper
Task: t_8e5a3821 · Lane: Software Quality Assurance
Status: DRAFT — awaiting sign-off before first production cron run
Scope: ingestion (scraper.ingest) + parsing (scraper.parsers) pipeline,
cache, HTTP/retry layer, and the python -m scraper CLI.
This checklist is the gate the acceptance criteria require ("the QA checklist is
reviewed and signed off before the first production cron run"). Every item is
either (a) verified by the automated suite in tests/, or (b) a known failure
mode with a mitigation. Do not start the production cron until every "Pre-cron"
box is ticked and a reviewer has signed the footer.
0. How to use this checklist
Green criteria (from the task):
1. Automated gate (must be green)
| # |
Check |
Verified by |
Passes? |
| 1.1 |
Parser codec round-trips AB/SB/ACR/SCR/HR/SJR/ABR + rejects junk |
tests/test_bill_id.py (21) |
☐ |
| 1.2 |
Schema/JSONL: field set, content-hash id, null-stripping, round-trip |
tests/test_schema.py (17) |
☐ |
| 1.3 |
Parsers: 3 dates, both chambers, floor/committee/hearing discrimination |
tests/test_parsers.py (35) |
☐ |
| 1.4 |
Malformed rows skip + log warning, batch survives; UTF-16/encoding edge |
tests/test_parsers.py |
☐ |
| 1.5 |
Ingest→parse end-to-end → valid JSONL schema; request-log recorded |
tests/test_integration.py |
☐ |
| 1.6 |
Failure modes: expected-404, antibot-403, transient-retry×3 |
tests/test_integration.py |
☐ |
| 1.7 |
Cache hit serves from disk with zero network; corrupt cache = miss |
tests/test_integration.py |
☐ |
| 1.8 |
Live smoke (opt-in); skipped by default |
tests/test_live_smoke.py |
☐ (skip) |
| 1.9 |
Coverage ≥ 85 % on scraper.parsers / scraper.bill_id |
pytest --cov |
☐ |
Known, deliberately-pinned behaviour (not bugs — see §4):
normalise_date("2026-08-31") returns None (the -NNN display-suffix
stripper runs before ISO); the site only ever emits MM/DD/YY, so this is
safe in practice. A fix must surface as a changed assertion, not a silent
regression.
2. Failure modes, detection & mitigation
Each row maps a known failure mode to how the system detects it, how the
automated suite proves the handling, and the operator mitigation. These are the
"site redesign, rate-limiting, encoding shifts" the task calls out.
2.1 Site redesign / structural HTML change · highest risk
| Aspect |
Detail |
| Symptom |
A CSS class or data-sort attribute the parser relies on (e.g. tr.floor-bill-details, td.floor_bill-measure a) is renamed/removed. |
| Detection (automatic) |
tests/test_parsers.py::TestArchivedSample parses the archived samples/assembly_dailyfile_latest.html and asserts ≥150 bill rows / ≥4 hearing rows. A structural break drops the count → red gate. Also: test_happy_path_ingest_then_parse_then_jsonl asserts AB 28 and SB 795 survive the full pipeline. |
| Runtime symptom |
Parse yields 0 (or a few) records where the prior run yielded ~150. |
| Mitigation |
Roll back to last good cache/raw/<date> (never deleted). Re-scrape selectors from the live page, update DAILY_FILE_PARSE_SPEC.md §3 and the parser selectors, re-record a fresh sample in samples/, re-run the gate. |
| Watch |
A sudden count drop is the #1 leading indicator — pair the cron with a "record-count anomaly" alert (count vs. 7-day median). |
2.2 Rate-limiting / 429
| Aspect |
Detail |
| Symptom |
httpx 429 (or 5xx) — the source throttles on volume. |
| Detection (automatic) |
tests/test_integration.py::test_transient_retries_three_times proves the retry loop makes 3 attempts with exponential back-off [1.0, 2.0]s and then raises TransientError. |
| Runtime handling |
config.yaml → http.retry_status drives this; 429 should be added to that list if the source uses 429 (currently [500,502,503,504]). Backoff is capped at backoff_cap_seconds. |
| Mitigation |
Lower cron frequency; add 429 to retry_status with a longer cap; if sustained, route via the blocked-page-recovery skill. Alert is fired per attempt via the request log. |
| Watch |
A rising share of 5xx_retrying/transient_gave_up outcomes in cache/request_log.jsonl. |
2.3 Antibot / 403 (Senate PDF path) · documented, expected
| Aspect |
Detail |
| Symptom |
senate.ca.gov/dailyfile/publications/download/... returns a 67-byte 403 antibot challenge on programmatic fetch. |
| Detection (automatic) |
test_antibot_403_is_flagged proves a 403 → status="antibot", all_ok=False, alert fired, no retry-storm. |
| Runtime handling |
403 is classified antibot_status=[403]; it is NOT retried (a retry storm just wastes the throttle) — it alerts and stops. Mitigation is the blocked-page-recovery skill (headless browser + session pre-warm). |
| Mitigation / workaround |
Until antibot is solved, Senate data is unavailable; Assembly-only output is valid. Optionally enrich Senate floor/committee items from the leginfo billResultsClient/billHistoryClient alt (spec §5b). |
| Watch |
antibot appears in request-log outcomes only on the Senate source. |
2.4 "No publication today" / expected 404 · expected, not a failure
| Aspect |
Detail |
| Symptom |
Senate PDF path 404s on non-legislative days (weekends, recess). |
| Detection (automatic) |
test_expected_404_is_not_an_error proves 404 on a missing_is_expected_404 source → status="not_found" with "expected" in the note, and all_ok is not polluted by it. |
| Runtime handling |
config.yaml → senate_daily_file.missing_is_expected_404: true makes a 404 a clean exit-0 outcome; cron mail fires only on real failures (antibot/transient/unexpected-404). |
| Watch |
A 404 on the Assembly source (which has no missing_is_expected_404) is a warning-level alert, not a clean no-file. |
2.5 Encoding shifts / bad charset
| Aspect |
Detail |
| Symptom |
A page changes its declared charset (UTF-8 → Windows-1252, adds a BOM, etc.) and produces mojibake or crashes a naive decode. |
| Detection (automatic) |
tests/test_parsers.py::test_utf16_edge_case_does_not_crash writes a UTF-16/BOM file and asserts the parser survives (logs an encoding warning, returns []) rather than aborting the batch. |
| Runtime handling |
scraper.parsers._read_text sniffs the charset from <meta>/BOM and falls back to utf-8/replace, logging a warning on replacement chars. |
| Mitigation |
On persistent mojibake the field values are corrupted but the pipeline does not crash; inspect the warning in logs and re-fetch. |
| Watch |
replacement chars detected warnings in the request/app log. |
2.6 Corrupt / truncated cache
| Aspect |
Detail |
| Symptom |
A crash mid-download leaves a truncated raw file, or a meta sha256 mismatches the bytes. |
| Detection (automatic) |
tests/test_integration.py::test_cache_corruption_is_treated_as_a_miss proves a sha mismatch is treated as a cache miss, so a good fetch re-runs instead of serving poison. |
| Runtime handling |
RawCache.put is atomic (tmp + os.replace); get verifies sha256 before serving. |
| Watch |
Repeated misses on a date that was previously cached suggests disk corruption — inspect cache/raw. |
2.7 Malformed rows / bad data within an otherwise-valid page
| Aspect |
Detail |
| Symptom |
A floor row has no bill link, a non-numeric item cell, or a hearing row with neither committee nor date. |
| Detection (automatic) |
test_malformed_rows_skipped_not_aborted proves bad rows are skipped per-row with a WARNING and the batch (good + recoverable rows) still completes. |
| Runtime handling |
scraper.parsers skips+warns, never aborts; bad rows are logged for triage. |
| Watch |
A spike in per-row WARNING lines indicates an upstream data-quality regression. |
2.8 Dependency / import availability
| Aspect |
Detail |
| Symptom |
httpx/PyYAML/beautifulsoup4/lxml missing in a fresh cron env. |
| Detection |
Import guards in scraper/config.py and scraper/http_client.py raise a SystemExit with a clear "install with pip install …" message (not a bare ModuleNotFoundError). |
| Mitigation |
requirements.txt pins the working versions (Python 3.9). The cron wrapper should pip install -r requirements.txt (or run in a venv with them pre-installed) before each run. |
| Watch |
A run that exits 2 with the "… is required" message means the cron env is missing a dep. |
2.9 P2 finding: retry-storm on non-retryable 4xx · defect, regression-guarded
| Aspect |
Detail |
| Symptom |
scraper/http_client.py has a catch-all return "transient" for any non-404/non-antibot HTTP status. A permanent 4xx such as 400 is retry-stormed 3× with back-off [1.0, 2.0]s — identical to a transient 5xx, even though the code's own comment says it "should not retry-storm." |
| Detection (automatic) |
tests/test_integration.py::TestRetryStormRegressionGuard::test_retry_storm_on_4xx_is_currently_3_attempts asserts the current buggy behaviour (3 attempts, sleeps == [1.0, 2.0]), keeping the suite green. The companion test_non_retryable_4xx_must_give_up_on_attempt_1 is a strict xfail that will flip to a failure when the engine is fixed, signalling "finding resolved, remove/convert this guard." |
| Runtime impact |
A 400 from the source causes 3 full fetches + 3 request-log entries where 1 would suffice. Not a data-corruption risk; a waste of the request throttle and a noise amplifier in the request log. |
| Fix |
In scraper/http_client.py, narrow the catch-all so that only transient-network faults and the explicit retry_status list are classified transient; a 4xx that is not 404/antibot/retry_status should be classified a non-retryable failure (a single-attempt terminal state), or retry_status should be expanded to exclude it. |
| Watch |
This xfail is a trip-wire; a strict-xfail going green is a signal to convert it to a plain test and re-baseline the counts. |
3. Rollback & recovery procedure
The pipeline is designed so that a bad run never destroys a good one — the
cache is append-only on the network side and atomic on the disk side.
3.1 Pre-cron pre-flight (run every time, before first cron)
3.2 Rollback on a bad run
- Do not delete the cache.
cache/raw/<source>/<date>.<ext> is the source
of truth and is append-only; a failed new fetch does not overwrite a prior
good cache hit.
- To serve "last known good": re-ingest with the default
force=False — the
cache serves the last good bytes; only a --force re-fetch reaches the
network.
- Re-parse the cached bytes with
python -m scraper parse --input cache/raw/…
— the parse is a pure function of the bytes, so re-parsing is safe.
- If the parser selectors broke (red gate, §2.1): roll back the parser commit,
re-parse the previous cached bytes with the old code, and hold the new
sample + selector changes until the gate is green again.
- If a corrupt cache is suspected (§2.6): delete only the affected
cache/raw/<source>/<date>.* + its .meta.json sidecar; the next non-
forced run re-fetches. (A sha-mismatch already forces a re-fetch
automatically, so this is belt-and-braces.)
- Record the incident in
cache/request_log.jsonl review and, if it changed
upstream, refresh the archived sample in samples/ and the parse spec.
3.3 Recovery from each failure mode
| Mode |
Recovery |
| 2.1 redesign |
Roll back parser commit; re-derive selectors from live page; new sample; re-gate. |
| 2.2 rate-limit |
Back off cron frequency; add 429 to retry_status; or blocked-page-recovery skill. |
| 2.3 antibot (Senate) |
Use blocked-page-recovery skill (headless + session pre-warm); meanwhile run Assembly-only. |
| 2.4 expected 404 |
No action — this is normal; confirm it's not_found + "expected". |
| 2.5 encoding |
Re-fetch; confirm charset in the warning; update _read_text sniff list if a new charset appears. |
| 2.6 corrupt cache |
Delete affected cache entry (+meta); re-fetch. |
| 2.7 malformed rows |
Triage the logged WARNINGs; an upstream data regression, not a pipeline bug. |
| 2.8 missing dep |
pip install -r requirements.txt in the cron env. |
| 2.9 retry-storm(4xx) |
Engineer narrows the transient catch-all in http_client.py; re-baseline the xfail guard (§2.9). |
4. Test suite map (what lives where)
| File |
Covers |
Count |
tests/test_bill_id.py |
scraper.bill_id codec: parse/normalise/origin/display/supplement + negatives |
21 |
tests/test_schema.py |
scraper.schema BillAction: fields, content-hash id, JSONL round-trip |
17 |
tests/test_parsers.py |
scraper.parsers: sample + 3 dates + both chambers + robustness/edge |
35 |
tests/test_integration.py |
ingest→parse end-to-end JSONL schema + 404/antibot/transient/cache + P2 retry-storm guard |
12 |
tests/test_live_smoke.py |
opt-in live-URL smoke (skipped unless CALI_SCRAPER_SMOKE=1) |
2 |
Deterministic suite (no network) total: 84 passed (+ 1 strict xfail, a
regression trip-wire on the §2.9 retry-storm finding). Live smoke: 2, skipped by
default so CI is independent of external availability. Full run is 84 passed, 2 skipped, 1 xfailed; coverage on the parser core (scraper.parsers +
scraper.bill_id) is 92.0 %, which clears the ≥ 85 % fail_under gate.
5. Sign-off
Gate: no production cron run starts until 1.1–1.9 are green and the §3.1
pre-flight list is ticked.
| Role |
Name |
Date |
Signature |
| QA (author) |
software_quality_assurance |
______ |
____________ |
| Engineer (parser lane) |
__________ |
______ |
____________ |
| Manager / release |
__________ |
______ |
____________ |
Reviewer note: this checklist is a DRAFT pending human sign-off. The automated
half (1.1–1.9) is enforced by pytest/pytest --cov and is not a substitute
for a human ticking §3.1.