Skip to content

Fail-closed ledger feeds: required pins, schema-validated rows, vendored bytes, typed units and periods#391

Open
MaxGhenis wants to merge 5 commits into
mainfrom
ledger-feed-hardening
Open

Fail-closed ledger feeds: required pins, schema-validated rows, vendored bytes, typed units and periods#391
MaxGhenis wants to merge 5 commits into
mainfrom
ledger-feed-hardening

Conversation

@MaxGhenis

Copy link
Copy Markdown
Contributor

Steps 3 of the ledger-review hardening (~/thesis-brief/ledger-architecture-review-sol-2026-07-10.md, findings 7–10). The release build no longer accepts a ledger feed on faith.

Loader (populace.build.ledger_artifact)

  • require_pins=True rejects bare consumer-facts files outright and makes BOTH external hash pins (expected_facts_sha256, expected_manifest_sha256) mandatory and verified (finding 7: the current certified US release was built from a bare, pin-less 131 MB feed that exists only on the build machine).
  • validate_rows=True (default) validates every row against the vendored consumer_fact.v1 JSON Schema before it can compile into a calibration target, plus unique aggregate_fact_key (finding 8). The validator (populace.build.ledger_schema) is stdlib-only per this module's duck-typing doctrine, implements exactly the constructs the schema uses, and raises at import on any construct it doesn't implement — a future schema revision cannot silently weaken the pin. The vendored schema's sha256 is pinned by test.
  • Profile files re-hash against the manifest; the upstream pre-newline hash bug is accepted only as explicit legacy_pre_newline semantics recorded in provenance (never silently). Pairs with PolicyEngine/ledger's consumer-hardening PR which fixes the hash at build and verifies on load.

Release CLI (tools/build_us_fiscal_refresh_release.py)

  • --ledger-facts-sha256 and --ledger-manifest-sha256 are now required; the loader runs fail-closed.
  • The exact verified artifact bytes are vendored into releases/<id>/ledger_artifact/ with a vendored_manifest.json (per-file sha256 recomputed after copy; divergent copies fail the release), and the vendored block is recorded in both build and release manifests.

Unit/scale gate (finding 9 — the thousands-vs-millions class)

  • Every LedgerTargetReference and LedgerTargetMapping measure declares its expected unit (+ explicit value_scale); a fact whose unit contradicts the declaration is a hard compile-time error naming both units, and an undeclared unit never passes silently. All checked-in references/mappings updated.

Typed periods (finding 10)

  • _selector_period_invariant_key no longer strips TY/CY/FY distinctions: tax-year, calendar-year, and fiscal-year facts never collapse into one "latest" series. References may declare period_type; matching multiple period types without declaring one is an ambiguity error, and compiled metadata records the fact's full typed period.

Verification

  • uv run ruff check . clean; uv run pytest -q green except one pre-existing live-network failure on clean main (populace-data certified-release pointer test, untouched by this branch).
  • Adversarial tests included: wrong-unit fact rejected naming both units; bare/unpinned/wrong-pin feeds rejected; vendored-copy tamper detected; TY-vs-CY facts never merge and ambiguity fails closed; structurally incomplete row rejected with field path; duplicate aggregate_fact_key rejected.
  • populace-build wheel verified to contain the vendored schema.

🤖 Generated with Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

CI note: the matrix failure (test_existing_certified_release_metadata_resolves_through_latest_pointer) is inherited from main — the certified pointer moved to the new Build J release (populace-us-2024-buildj-sparse-rmloss100-75d5add-20260710T094201Z) at 09:42Z today while the test still pins the July 9 Build I id. Main's Tests run at 10:57Z fails identically, before this branch existed. Leaving the pin bump to the Build J lane rather than folding an unrelated populace-data edit into this PR. Everything this PR touches passes (ledger_schema/ledger_artifact/ledger_targets/builder suites green on both 3.13 and 3.14 in the same runs). One observation for the Build J lane: today's release records the same bare ledger feed (consumer_facts_buildh_v8.jsonl, 94b7155f…, no manifest/pins) — after this PR merges, the next rebuild will need the artifact-directory + pins form.

MaxGhenis and others added 5 commits July 10, 2026 12:17
…its and periods

Ledger architecture review 2026-07-10, findings 7-10. The release build
no longer accepts a feed on faith:

- load_ledger_consumer_artifact gains require_pins (bare feeds rejected;
  both external hash pins mandatory and verified) and validate_rows
  (every row validated against the vendored consumer_fact.v1 schema —
  stdlib validator that raises on constructs it does not implement —
  plus unique aggregate_fact_key). Profile bytes re-hash against the
  manifest, with the upstream pre-newline hash accepted only as an
  explicit legacy semantics recorded in provenance.
- build_us_fiscal_refresh_release requires both pins, loads fail-closed,
  and vendors the exact verified artifact bytes into
  releases/<id>/ledger_artifact/ with a recomputed-hash manifest — a
  hash alone cannot reconstruct a feed whose source file disappears
  (the current certified US release's feed exists only on the build
  machine; this makes that impossible going forward).
- Every target mapping declares its expected unit and scale: a fact
  whose unit contradicts the mapping is a hard error at compile time
  (the thousands-vs-millions class), and undeclared units never pass
  silently.
- Latest-fact selection carries the full typed period: tax-year,
  calendar-year, and fiscal-year facts never collapse into one series;
  a reference matching multiple period types without declaring one is
  an ambiguity error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sol round-2 findings 7 (numeric) and 12, populace side:

- Consumer facts parse with a parse_constant that rejects NaN, Infinity,
  and -Infinity, and every numeric value must be finite. Python's default
  json.loads accepts those tokens even though they are not valid JSON, so
  a non-finite value could enter a schema-valid, hash-valid artifact.

- The pre-newline profile hash is now accepted only when the manifest
  predates the fix (no consumer_fact_schema_sha256), matching Ledger's
  loader. Recording a downgrade in provenance is not the same as refusing
  it, and a post-fix pinned artifact must match exact bytes.

- The release build passes require_pins=True, so a manifest-listed profile
  that is missing on disk fails the release instead of being silently
  skipped and then vendored incomplete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The extracted _load_release_ledger_artifact helper was defined but never
called, and referenced an unimported type. main() now loads through it,
so the require_pins=True path is the only way a release reads its feed
and is directly testable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sol round-2 finding 4: the dynamic US fiscal reference builders set
expected_unit=_measure_unit(fact) — copying the expectation from the very
fact being checked, so a drifted-unit fact validated against itself. They
now read _expected_unit_for_production_measure(fact), a committed
measure->unit map keyed independently of the incoming fact; a fact whose
unit differs is a hard error. value_scale must be explicitly declared
(value_scale_declared) on a production reference — an undeclared or
defaulted scale is rejected rather than silently applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nt unit

Rebasing onto main (SNAP caseload targets, #371) surfaced two flaws in the
finding-4 unit gate:

- expected_unit was checked before the aggregation/value checks, so a fact
  that cannot compile at all (a mean fact with no time_mean contract) failed
  on the missing declaration rather than on its real cause. The gate now runs
  after the structural checks.
- the committed expected-unit map assigned usd to every indicator measure,
  but USDA SNAP average-monthly caseload is a count. Count-based indicators
  now map to their real denomination (SNAP -> count, CMS -> people), so the
  household caseload fact (unit 'count') passes and a genuine drift still
  fails the gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MaxGhenis MaxGhenis force-pushed the ledger-feed-hardening branch from 340490a to 8379b05 Compare July 10, 2026 16:23
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.

1 participant