Scope Logbook chains by country and dataset line: archives, live store, and tooling - #702
Scope Logbook chains by country and dataset line: archives, live store, and tooling#702juaristi22 wants to merge 3 commits into
Conversation
The archive was a single chain at the repo root, so UK migration attempts had nowhere to land that did not interleave them with the US pool lineage. Archives become logbook/<country>.jsonl, one hash chain per country, with logbook-spool/<country>/ mirroring the shape. logbook.jsonl -> logbook/us.jsonl and its 28 spool rows move unchanged (same sha, same tail digest). That chain spans three US pipelines and stays as it is: a row's digest commits to its predecessor, so rows can never be re-rooted into another archive, and the chain cannot be divided retroactively. The split stops at country. Finer scopes would multiply chains as fast as epics appear, and the epic story belongs in the archival commit message and the row's code_pin, not in the file layout. The operational cost of one chain per country is that concurrent builds in the same country fork it - the predecessor is chosen at build time - so builds within a country run one at a time; the README says so. Tooling: export requires --archive and refuses a directory (an export extends exactly one chain, so the operator names it) and refuses a missing --source rather than defaulting to a root spool that no longer holds rows; validate and render accept a directory and walk it chain by chain, rendering a section per country rather than merging independent chains into one table that would imply an ordering across them. The live store is unchanged and does not match this shape yet: it is one table enforcing a single global chain (single-genesis index, no-fork index, tail-checking trigger), so a second country's genesis row is rejected there until that project is reorganized. Recording is unaffected - remote availability was never part of build correctness. No build behaviour changes: drivers spool beside their output artifact and never write into the repository. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes the scope split the archive layout began: chain scope = <country>/<dataset-line>, derived from the pipeline name, one hash chain per scope in the database exactly as in the git archives. Migration (20260818000000_logbook_chain_scopes.sql): chain_scope() with the three legacy US pipelines hardcoded to the grandfathered mixed us scope - the live rows span all three with chain links crossing them, so pipeline itself cannot key the chain, and nothing is re-scoped retroactively (scope is derived at check time from pipeline, which already rides the hashed payload; every existing row_digest is unchanged, verified). Every other pipeline must declare <country>-<dataset-line>-... and derives its scope from the first two tokens; a new name opens a new scope and demands genesis, fail-closed. Genesis index, trigger count/tail, and the advisory lock become per-scope so scopes append concurrently; builds_unique_predecessor stays global (a fork is a fork wherever it happens). UK pipelines adopt the convention before any UK row exists: uk-households-staging (national line), uk-locals-rowwise and uk-locals-candidate (local line - separate scope because rowwise candidates build concurrently with national runs). uk/firms is open for the firm-microdata seam with its own genesis. Remote export becomes scope-aware: the archive path names the scope, the PostgREST query filters by it (in-list for legacy us, like-prefix for derived scopes), and every fetched row's scope is re-verified client-side before chain ordering - the server filter is untrusted input to a chain verifier. Verified against a real Postgres loaded with the 28 production rows: old-trigger load, migration on the populated table, legacy-chain continuity, independent uk/households and uk/firms geneses, stale-tail and cross-scope refusals naming the scope, fail-closed new-scope opening, and byte-stable archived digests. That battery ships as tests/test_logbook_chain_scopes_pg.py (importorskip pgserver/psycopg; CI skips it, any reviewer can run it locally). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
60a8e98 to
9081120
Compare
vahid-ahmadi
left a comment
There was a problem hiding this comment.
The scope model is right and the hard parts are done carefully. Verified: the Python _chain_scope and SQL logbook.chain_scope twins agree exactly (same grandfather list, same pattern, same split — I checked the regexes against each other, not just the tests); scope being derived from the hashed pipeline rather than stored is the property that keeps every archived row_digest valid and makes rows unmovable between chains; the grandfathered mixed us chain honestly declared unsplittable is better than a retroactive re-scope that would have had to rewrite history; keeping builds_unique_predecessor global is the correct call for exactly the reason the comment gives; and treating the server-side export filter as untrusted input to the chain verifier — re-checking every fetched row's scope client-side — is the right posture for a lineage tool. The archive move as pure renames (validate reports the same 28 rows and tail on this branch, which I ran), the loud two-token edge, and the committed-but-skipped Postgres battery with its invocation in the docstring are all good discipline. Tests pass for me and validate is clean.
One decision this PR makes silently and shouldn't: uk-frs-spine derives scope uk/frs in both twins, and the PR is silent about it.
The body renames every other UK pipeline into the ratified vocabulary (uk-households-staging, uk-locals-rowwise, uk-locals-candidate) — but tools/build_uk_frs_spine.py keeps _PIPELINE = "uk-frs-spine", and the E2–E4 acceptance runs have already landed spool rows under that name. At the next archived spine run, uk/frs opens as a de-facto scope. That's a problem under the README's own definition either way you read it:
- "A scope is a line of data work …
households,locals,firms, and similar base-data families. Different base data need different scopes." The spine and the national staging are the same base data — the spine is the households line's early stage, and the staging build consumes its output. By the stated rule they share a line, which argues foruk-households-spine→uk/households: the spine→staging provenance then lives in one chain, and the serialization you get (spine and staging runs in one scope append one at a time) mirrors how they actually run. - If instead the spine is deliberately its own line — defensible if spine artifacts are meant to version independently of staging runs — then
frsis a source name, not a base-data family, and the decision belongs in the README next tohouseholds/locals/firms, not in whatever pipeline name E2 happened to pick before this vocabulary existed.
Either answer is fine; what shouldn't survive is the third state, where the scope opens implicitly at the next full-scale run and the vocabulary is ratified by accident.
Related hardening, worth considering here or as a follow-up: genesis currently accepts any well-formed name, so scope opening is fail-closed against continuation but open-world against vocabulary. The genesis-demand mechanism correctly stops a typo'd us-pool-inc3 from silently extending us — but run the typo'd pipeline without a predecessor and it happily opens scope us/pool at genesis; same for uk-huseholds-staging → uk/huseholds. A committed scope allowlist (a declared-scopes resource the CHECK and the CLI both consult, so opening a scope is a reviewed diff rather than a side effect of any matching name) would close that, and it's the same closed-world move this repo already makes for operation kinds and gate entries. The migration being append-only on prod makes this cheaper to add now than after a stray scope exists.
Neither point blocks the mechanism — but the uk-frs-spine question needs an answer before the next full-scale spine run mints the scope, so it's worth settling in this PR while the chain is still empty.
…ard local exports
Three findings, one round:
- Vahid: uk-frs-spine would have silently minted scope uk/frs while the
PR renamed everything else. Settled by ratifying uk/frs as the line's
scope (Maria's ruling: a dataset line is named by its base data - this
line is FRS-derived survey microdata carrying persons, benunits, and
households, which is why the interim 'households' token over-named one
entity and why 'spine', a microcosm mechanism, cannot be a line). The
E2 name uk-frs-spine stands; uk-households-staging re-renames to
uk-frs-staging to join it; scope uk/frs carries spine->staging
provenance in one chain.
- Vahid: genesis was fail-closed against continuation but open-world on
vocabulary. logbook.scope_declared() closes it: the ratified set is
deliberately minimal ({us, uk/frs} - a scope is ratified when its line
starts archiving, so uk/locals waits for the local-areas line's first
archival; its drivers keep the convention names and their rows spool
regardless, since recording never consults the list). The CHECK and the
trigger refuse an unratified scope by name before genesis; opening a
scope is a three-place reviewed diff (migration, DECLARED_SCOPES
mirror, README).
- Adversarial review: the local --source export never derived a scope
from the archive path, so a wrong spool would chain validly into the
wrong archive - the chain verifier authenticates payloads, never
filenames. _archive_scope now governs both export branches; wrong-scope
candidates are refused by name before anything touches the archive.
Postgres battery re-run against the updated migration: unratified
geneses refused (uk/locals, uk/firms, us/pool), cross-scope independence
proven by uk/frs opening while us holds rows, archived digests
byte-stable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both points taken, plus one finding from an adversarial pass run in parallel — all three landed in this review round as one commit. The The allowlist — added, exactly as the closed-world move you named. The adversarial pass found the mirror-image of your export observation: the local 🤖 Generated with Claude Code |
|
Verified cc673cc in code: The The adversarial finding is the one that would have hurt: a mis-scoped local export chains validly — the verifier authenticates payloads, never filenames — so the lineage corruption would have been permanent and invisible to every downstream check. Catching the mirror-image of the remote fix before any UK chain exists, with a regression test on each branch, closes the class rather than the instance. Nothing further from me — ready from my side. |
Part of workstream A of #665. The Logbook was one chain at the repo root and one global chain in the live store, so the UK migration's build attempts had nowhere to land that did not interleave them with the US pool lineage — and any UK row recorded against a stale global tail while the US ladder appends concurrently would be permanently orphaned (its
row_digestbakes in the predecessor; no reconciliation can repair it). This PR completes the split on both sides at the ratified granularity: chain scope =<country>/<dataset-line>, grandfathered so nothing existing moves.The scope model
A scope is the unit of lineage: one hash chain per scope, in the git archives and in the database. Scope is derived from
pipeline(already inside the hashed payload), never stored — every existingrow_digeststays valid, and a row cannot be moved between chains without breaking its own digest.us-2024-release,us-pool-inc2,us-stacked-pool) are hardcoded to the grandfathered scopeus. The live data requires this: the 28 archived rows span all three with chain links crossing them, so pipeline itself cannot key the chain. That mixed chain keeps extending under those names forever; nothing is re-scoped retroactively.<country>-<dataset-line>-…and derives scope<country>/<second-token>. A new pipeline name opens a new scope and demands genesis — fail-closed scope opening (a typo'dus-pool-inc3gets a loud genesis demand, never a silent continuation ofus).frsis the FRS-derived survey-microdata line — persons, benunits, and households, with spine, staging, and imputation stages sharing one chain — andlocalsis the local-areas product line. Not entities (the interimhouseholdsover-named one), not mechanisms (spineis the microcosm base-frame concept), not epics. Scopes are also where builds serialize: the predecessor is chosen at build time, so concurrent lines need separate chains.logbook.scope_declared()carries exactlyusanduk/frs— deliberately minimal: a scope is ratified when its line starts archiving. Theuk-locals-*drivers keep the convention names and their rows spool locally regardless (recording never consults the list);uk/localsis a one-line ratification when that line first archives. An unratified scope is refused by name even at genesis, so a typo'd pipeline cannot mint a stray scope on an append-only store. Opening a scope = a reviewed three-place diff (migration +DECLARED_SCOPESCLI mirror + README).Layout (committed archives)
logbook.jsonl→logbook/us.jsonland its 28 spool rows moved as pure renames (0-line diffs; same sha44ee49ea…, same tail6eda41f8…).validate/renderwalk the directory chain by chain;exportnames exactly one chain and refuses a directory.The database half (
supabase/migrations/20260818000000_logbook_chain_scopes.sql)logbook.chain_scope(pipeline)(IMMUTABLE — grandfather list first, then the two-token derivation), a CHECK that every pipeline declares a scope,builds_single_genesis→builds_single_genesis_per_scope, andenforce_build_chain()scoped throughout: per-scope advisory lock (hashtext(scope)— scopes append concurrently, appends within a scope serialize), per-scope count/tail/genesis, and an explicit chains-never-cross-scopes guard.builds_unique_predecessordeliberately stays global: digests are unique table-wide, so two rows claiming one predecessor is a fork wherever it happens — scoping it would weaken it.Applied by the project owner to prod (same flow as the R2 setup); the writer key is unaffected.
Verified against a real Postgres (embedded server, the four existing migrations applied with pgcrypto's
digestshimmed over built-insha256, then this one, loaded with the actual 28 production rows):uschain from its real tailuk-frs-*row with a US-tail predecessor is refused; genesis accepteduk/frsopens at genesis whileusholds rows; unratifieduk/localsanduk/firmsare refused even at genesisuk-frs-*row against a stale scope tail is refused, naming the scopeus-pool-inc3is refused outright (unratified scope) — and does not disturbusrow_digestis unchanged after all of the aboveThat battery ships as
tests/test_logbook_chain_scopes_pg.py—pytest.importorskip("pgserver")/("psycopg"), so CI and the default venv skip it (no dependency changes) while any reviewer can run it locally; the docstring carries the exact invocation and the pgcrypto-shim caveat.UK pipelines named before any UK row exists
uk-national-staging→uk-frs-staging(scopeuk/frs, joining the E2-nameduk-frs-spinewhich was right all along);uk-rowwise-geography/uk-rowwise-candidate→uk-locals-rowwise/uk-locals-candidate(scopeuk/locals, separate because rowwise candidates build concurrently with national runs). Only local spool receipts carry the old names and they never enter an archive or the DB — the one existing UK row is structurally un-insertable either way (its predecessor is the US tail), and stays a local receipt; theuk/frschain opens at the campaign's next full-scale run, genesis in both the archive and the live store.Remote export is scope-filtered
export --remotepreviously fetched every row and required one connected chain — correct on a single-scope table, broken the moment a second scope exists. Both export branches now derive the scope from the archive path (logbook/us.jsonl→us;logbook/<cc>/<type>.jsonl→<cc>/<type>; anything else — including an unratified scope — refused by name). Local exports get the same discipline (adversarial-review finding: the chain verifier authenticates payloads, never filenames, so a wrong spool would have chained validly into the wrong archive); the remote branch additionally filters server-side (pipeline=in.(…)for the legacy set,pipeline=like.<cc>-<type>-*for derived scopes), and re-verifies every fetched row's scope client-side before chain-ordering — the server filter is untrusted input to a chain verifier. Known edge, deliberately loud: a two-token pipeline with no third segment (e.g. exactlyuk-households) derives a scope but escapes thelikefilter; the export then fails on the chain gap rather than producing a wrong chain.Verification
tools/logbook.py validate: 28 rows, tail6eda41f8…, unchanged._chain_scopetwin (documented mirror of the SQL), remote-filter URL construction for both scope shapes, wrong-scope row refusal, non-scope archive refusal.Refs #628, #666, #665.
🤖 Generated with Claude Code