Skip to content

The database leaves git: derived artifact, built in CI, published to Supabase storage - #74

Merged
MaxGhenis merged 5 commits into
mainfrom
db-out-of-git
Aug 20, 2026
Merged

The database leaves git: derived artifact, built in CI, published to Supabase storage#74
MaxGhenis merged 5 commits into
mainfrom
db-out-of-git

Conversation

@MaxGhenis

Copy link
Copy Markdown
Contributor

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

MaxGhenis and others added 3 commits August 19, 2026 22:53
…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>

@vahid-ahmadi vahid-ahmadi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 builduk_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

  1. No integrity manifest published. <sha>.db.gz + latest.db.gz go up, but the content_hash CI just computed doesn't — a consumer fetching latest.db.gz can't verify a complete download or learn which commit it reflects without trusting the transport. Cheap: upload a latest.json {sha, content_hash, gzip_bytes} alongside, written after both objects (also makes the two-object upsert sequence detectably atomic).
  2. conftest.py uses a stale local DB silentlyif not db.exists() means a developer with last week's data/scorecard.db runs today's suite against old data (CI is immune: fresh checkout). A one-line staleness warning (or rebuild when the DB predates scorecard_db/'s newest mtime) would close the local footgun.
  3. 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 ::warning in 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.

MaxGhenis and others added 2 commits August 20, 2026 07:08
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>
@MaxGhenis
MaxGhenis merged commit 3e2cdd2 into main Aug 20, 2026
3 checks passed
@MaxGhenis
MaxGhenis deleted the db-out-of-git branch August 20, 2026 12:42
vahid-ahmadi pushed a commit that referenced this pull request Aug 20, 2026
`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>
DTrim99 added a commit that referenced this pull request Aug 21, 2026
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>
vahid-ahmadi pushed a commit that referenced this pull request Aug 21, 2026
vahid-ahmadi pushed a commit that referenced this pull request Aug 21, 2026
vahid-ahmadi pushed a commit that referenced this pull request Aug 21, 2026
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
vahid-ahmadi pushed a commit that referenced this pull request Aug 21, 2026
vahid-ahmadi pushed a commit that referenced this pull request Aug 21, 2026
vahid-ahmadi pushed a commit that referenced this pull request Aug 21, 2026
vahid-ahmadi pushed a commit that referenced this pull request Aug 25, 2026
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
vahid-ahmadi pushed a commit that referenced this pull request Aug 25, 2026
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
vahid-ahmadi pushed a commit that referenced this pull request Aug 25, 2026
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
vahid-ahmadi pushed a commit that referenced this pull request Aug 26, 2026
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
vahid-ahmadi pushed a commit that referenced this pull request Aug 26, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants