The database leaves git: derived artifact, built in CI, published to Supabase storage - #74
Conversation
…pped Max: "shouldn't this be in supabase". The rebuild experiment settled the design: the ENTIRE database regenerates from committed inputs in ~4 seconds, logically deterministic (two builds, identical content hash) — and the diff against the committed blob showed the blob silently LAGGING the code (520 solo exhibits it never received, placeholder computed_at stamps, 8,590 legacy results whose current-law baseline the producers now stamp). A committed derived artifact drifts; a built one cannot. - scorecard_db/build_db.py: the ONE build entrypoint — dependency- ordered ingest chain (urban -> harvest -> RV -> platform -> solo -> diagnoses -> campaign US -> UK externals -> FRR -> campaign UK) + order-insensitive content_hash. Refuses to overwrite (from-scratch only, nothing to lose). - data/scorecard.db untracked (.gitignore) — kills the 55 MB blob GitHub warns on, the poor-delta history bloat every ingest PR added, and unblocks the long-tail sources the size cap was holding. - CI: builds fresh, asserts determinism (two builds, hash equality), runs the suite against the built file (all committed-DB tests work unchanged), uploads the artifact; on main pushes, publishes <sha>.db.gz + latest.db.gz (~6 MB from ~58 MB) to the scorecard-artifacts bucket on the PE Supabase project (created; repo secrets SUPABASE_SCORECARD_URL/KEY set; free-plan 50 MB per-object cap is why gzip, with ~9x headroom). - Committed JSON feeds are byte-identical when exported from the fresh build — the app layer is fully insulated. Suite: 243. System of record stays git (vendored sources, staged files, code); the DB is now honestly what it always claimed to be — a cache. Postgres-as- database-of-record was considered and rejected: it would break the reviewable-data-diff and code+data-atomic-merge properties every gate this week has leaned on. History slimming (filter-repo of the old DB blobs) is deliberately left for just before the repo-public flip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI exposed the one non-vendored input: ingest_urban/ingest_solo read ~/populace-sotsn-takeup/comparison (machine-local), so the rebuild only worked where that clone exists. The nine files (2.4 MB of text CSVs + meta) are now vendored under sources/populace-sotsn-comparison and both modules default there. Rebuild from the vendored copies produces the IDENTICAL content hash (0c63dff1...); suite 243. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sol's three findings: 1. CI now runs `git diff --exit-code` after the determinism builds AND after pytest — the builds rewrite tracked feeds (lanes, ledger, uk_resolved) as ingest side effects, so the tree must equal HEAD or a committed artifact has gone stale relative to the code that regenerates it. (App copies are tracked, so the same gate covers data/ vs app/public/data drift.) 2. Fresh-clone workflow: tests/conftest.py session fixture builds the DB when absent (plain pytest works from a clean clone; no-op in CI); scorecard_db/README documents build_db as the canonical flow, the from-scratch refusal, and the empty-DB-from-ScorecardDB() trap. 3. pipeline/build_comparison.py now reads the vendored interchange and fails loudly when it is missing (it used to silently build empty). Plus sol's low-risk hardening: build_db refuses any duplicate (claim_id, computed_at) in pe_results — the exporters use the autoincrement id as an ORDER BY tie-breaker, so a tie would let physical insert order leak into populations.json (zero ties today). Rebased onto post-#50 main (the app-copy refresh commit was superseded by the merge and dropped). Hash unchanged: 0c63dff1.... Suite: 247; tree verified clean through build + suite locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7d3c842 to
ca1a76f
Compare
vahid-ahmadi
left a comment
There was a problem hiding this comment.
Review — verified end-to-end locally; sound, three should-mentions
Ran the whole contract on this branch (macOS): two from-scratch builds in ~4s each, content hashes match (0c63dff1…) and the artifacts are in fact byte-identical here — stronger than the logical-hash gate CI asserts. git diff --exit-code clean before and after the suite (the no-drift gate catches nothing on a fresh build, as it should), ruff format --check clean, 245 passed / 2 skipped against the built DB. PR CI: both jobs green, publish correctly skipping on a PR.
As the UK-lane owner, the finding I came to check: the UK chain is fully in the build — uk_externals lands 15,851 claims + 1,073 Ledger-routed rows (= the 16,924 of #48), uk_deductions 7, produce_uk/campaign_uk attach the 14 reckoner joins. Nothing drops out of the published DB. The build-step summary's blocked ledger (free_joins/obr_measures/two_child/uprating with reasons) is a nice honesty artifact.
Also verified: the (claim_id, computed_at) tie refusal in build_db.py:100-110 covers exactly the two ORDER BY computed_at, id consumers (export_populations.py:125, db.py:194) — the id-exclusion from the content hash is sound while that gate holds. And the publish job is push+main-gated with skip-on-missing-secrets, so fork PRs never touch credentials.
Should mention
- No integrity manifest published.
<sha>.db.gz+latest.db.gzgo up, but thecontent_hashCI just computed doesn't — a consumer fetchinglatest.db.gzcan't verify a complete download or learn which commit it reflects without trusting the transport. Cheap: upload alatest.json{sha, content_hash, gzip_bytes}alongside, written after both objects (also makes the two-object upsert sequence detectably atomic). conftest.pyuses a stale local DB silently —if not db.exists()means a developer with last week'sdata/scorecard.dbruns today's suite against old data (CI is immune: fresh checkout). A one-line staleness warning (or rebuild when the DB predatesscorecard_db/'s newest mtime) would close the local footgun.- Free-plan 50 MB object cap is fine at 6 MB gzipped, but the DB grew ~40% this week from the UK lanes alone; worth a
::warningin the publish step when the gzip crosses, say, 35 MB rather than discovering the cap as a hard 4xx.
None of these block. The vendoring of populace-sotsn-comparison (repo now buildable from a bare clone — confirmed, that's what my build ran on) and the refuse-to-overwrite build are both the right calls.
Sol's two round-2 blockers: 1. The session fixture ran after collection, but eight DB-existence skipifs evaluate DURING collection — a fresh clone silently skipped them all. The build now happens in pytest_configure (before any test module imports), so plain pytest runs the FULL suite from a clean clone. Verified by deleting the DB and running: 248 passed, zero DB-skips, DB rebuilt. 2. load_2026's missing comparison.csv returned empty maps even with the vendored directory present — a gutted interchange looked fine. It now raises on the file, with a regression test (dir present, comparison.csv absent -> FileNotFoundError). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`updated` is derived, not authored: sync_lane_feed() sets it from the max of the lane rows' own updated_at (2026-08-19) and rewrites the file. The rebase conflict resolution hand-set it to 2026-08-20, so every ingest reverts it — invisible while the DB was committed, but #74 builds the DB during collection, so the drift now fails the no-drift gate and test_app_data_copies_match_committed_data (the build rewrites data/ but not the app/public/ copy). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rebased onto current main (picks up #74's DB-leaves-git model + #71's baseline_key columns; clean three-way). Addresses Max's 8/20 re-gate — the injection/secrets/architecture classes passed; three findings remained. Finding 1 (critical — producer couldn't start): the Modal image put the cloned microcosm shards on PYTHONPATH without installing them, so microcosm.fit's eager quantile-forest import failed. Now pip-installs the shards (constraints file keeps the release-exact engines pinned), reads the installed versions back and asserts they equal the manifest, and runs a `backfill.py --plan` smoke before the multi-hour run so a missing dep fails fast. Finding 2 (critical — attestation declarative, not verified): - Ingest now VERIFIES the block: h5_sha256 is 64-hex, commit ids are hex, and both attested engine versions must equal the artifact's own `engine` block — the forged `engine=9.9.9` / `h5_sha256="x"` now fail. - Producer rehashes the ACTUAL H5 (cached or downloaded) and stamps the observed hash, verifying it against the manifest; merge() requires the partials' single surviving revision to EQUAL the attested producer/driver (all-old partials can't be relabeled under a new driver); the workflow cross-checks the harvested artifact's Modal call id against the spawner's recorded id. Finding 3 (high — post-#74 the workflow aborted): it `git add`ed the now- gitignored `data/scorecard.db`. Now commits only the raw artifact + source.json, and verifies by building the whole DB to a throwaway path (`scorecard_db.build_db`) — the DB is never staged. Tests: attestation verification (bad sha / non-hex commit / engine mismatch, plus the clean fixture still ingests). Full RV suite 45 pass; real ingest on a copy of the committed DB holds the non-RV invariant (675 results x 5 releases). Modal-runtime paths (shard install, H5 rehash, call-id cross-check) reasoned + unit-tested where stdlib-reachable; a live dry-run before enabling the schedule remains the last mile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. Blocking — the lane no longer vanishes from a post-#74 build. The adapter wrote standalone metadata and nothing registered in build_db.py, so a fresh CI-built DB held no trace of HMT at all — not even a zero. scorecard_db/ingest_hmt_distributional.py is the missing step: it emits no external_scores rows (deliberately, and it raises if the metadata ever claims otherwise), writes the lane row with the exact chart-cell accounting as its detail, and mirrors it into the committed feed under UK. Registered in build_db after uk_deductions. 2. The omission is tallied, not asserted. The registry listed figures and series but not the GROUPS, so "0 emitted" had no denominator. The eleven income groups are now registry data, and the adapter enumerates and checks every cell: 132 source marks = 0 emitted + 132 chart_not_digitized, by figure / income group / series, with the arithmetic raising if it does not close. The 30 policy components still reconcile 3+9+18 = 16+9+5. 3. Decile identity is data. The ten equivalised-net-income deciles plus the all-households bar are a closed uk_aliases vocabulary, with housing_costs=bhc and equivalisation=modified_oecd pinned beside them (the HBAI/UKMOD pattern), and HMT deciles are recorded as DISTINCT from UKMOD quintiles so nothing can alias them later. 4. Baseline identity is machine-enforced and per figure. 1.A and 1.B are changes against the no-policy-change world, which is now registered in baselines.py with its paragraph citation; 1.C is a post-policy LEVEL and keys current law. The assignment lives in the registry, is emitted into the metadata artifact, and an unregistered chart baseline raises — so a future HMT row cannot silently default to current_law. 5. The anchoring gate actually runs in CI. It was importorskip-gated on pypdf and pyyaml while CI installed only pytest, so the lane's strongest honesty guarantee never executed there. CI now installs both and the skip path is gone. The adapter also writes the trailing newline the committed metadata has, so wiring it into the no-drift build no longer dirties the tree. 6. The held-out relationship is registered with its evidence, before any numeric row can land rather than in an emergency when the first one does; and the diagnosis guidance now names the citable-known-issue gate (class + rationale + action_link) instead of saying an engine defect "may be inferred". Suite 264 passed, two builds agree on content_hash, no-drift clean, ruff format clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
Found in an integration review pass: uk/ukmod-cases-schema forked before #74 (the database leaving git), so it carried NO scorecard_db/build_db.py and still tracked data/scorecard.db as a committed binary. Its CI was therefore the PRE-#74 workflow — the determinism check and the no-drift gate had never run against this branch at all, so the green tick was a weaker check than it looked. Merging current main brings it under the current gates: it builds from scratch, two builds agree on content_hash, the tree is clean afterwards, and the suite is 325 passed (up from 287, because main's own tests come with it). No conflicts — main's deletion of the committed database wins over an untouched file on this side, so nothing resurrects it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
Same integration finding as uk/ukmod-cases-schema, which this branch stacks on: uk/calculator-oracles forked before #74, so it had no scorecard_db/build_db.py and still tracked the committed database. Its CI was the PRE-#74 workflow, so the determinism check and the no-drift gate had never run here either. Now under the current gates: builds from scratch, two builds agree on content_hash, clean tree afterwards, suite 359 passed (up from 321). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
GitHub runs a pull_request workflow from the PULL REQUEST'S OWN branch, not from the base. So a branch that forked before a gate was added keeps running the workflow WITHOUT it — and its green tick looks identical to a full one while meaning strictly less. That is not hypothetical. #74 made the database a derived artifact and added a determinism check and a no-drift gate. Branches forked before it kept the pre-#74 workflow, so neither gate had ever run against them, and they were reviewed and approved on the understanding that both had. An audit of every open PR found four in that state; two were mine (#49, #64, since fixed) and two are still open. gate-freshness.yml runs from the BASE via pull_request_target, so a stale head cannot skip it: the check is defined by main and applies to every PR regardless of what its own .github looks like. The gate set is DERIVED FROM THE BASE rather than hardcoded — it reads main's ci.yml and requires every determinism/no-drift line it finds to be present in the head's — so a gate added later is enforced on every open PR without anyone remembering to update the guard. It also refuses a branch that still tracks data/scorecard.db, whose build cannot have been from-scratch. Security: pull_request_target runs in the base repo's context, so this job NEVER checks out or executes pull-request code. It reads git metadata only and holds contents:read. A test asserts that, and asserts the guard cannot quietly become a pull_request trigger. Verified by running the exact logic against all 16 open PRs: 14 pass and exactly the two known-stale branches fail, with the reasons named. Suite 270 passed, ruff format clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
The probe workflow runs both registries, but pipeline/validate_budget_2026_registry.py arrives with #100 — so this branch failed on a missing file rather than on anything it owns. Guarded on existence, and the skip is ANNOUNCED via ::notice:: rather than silent. A gate that quietly does nothing reads as a gate that passed, which is exactly what #74/#95 were about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
The previous commit guarded pipeline/validate_budget_2026_registry.py and left validate_cgt_reform_spec.py (#102) failing the same way — a validator that lands on its own branch, absent on this ref. Folded both into one loop that skips a missing validator, announces every skip via ::notice::, and reports how many actually ran. Each starts enforcing the moment its PR merges, with no edit here. Announcing the skip is the point. A gate that quietly validates nothing reads as a gate that passed — the #74/#95 failure mode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
The rebuild experiment behind this: the entire 58,468-claim database regenerates
from committed inputs in ~4 seconds, deterministically (two builds, identical
logical content hash) — and diffing the rebuild against the committed blob
showed the blob silently lagging the code (solo exhibits never re-committed,
placeholder timestamps, unstamped legacy baseline provenance). A committed
derived artifact drifts; a built one cannot.
New: scorecard_db/build_db.py (the one dependency-ordered build entrypoint +
content hash). data/scorecard.db untracked. CI builds fresh, asserts
determinism, runs the suite against the build, uploads the artifact, and on
main publishes .db.gz + latest.db.gz (~6 MB) to the scorecard-artifacts
bucket on the PE Supabase project (bucket created, repo secrets set).
Committed JSON feeds are byte-identical from the fresh build — the app layer
is insulated. Postgres-as-database-of-record was considered and rejected (it
breaks reviewable data diffs and atomic code+data merges). History slimming of
the old blobs is deliberately deferred to just before the repo-public flip.
Dual gate: fable review done; sol pass next.
🤖 Generated with Claude Code