Skip to content

Enforce consumer-artifact integrity: schema-validated rows, real profile hashes, dimension cross-tab resolution#87

Open
MaxGhenis wants to merge 3 commits into
mainfrom
ledger-consumer-hardening
Open

Enforce consumer-artifact integrity: schema-validated rows, real profile hashes, dimension cross-tab resolution#87
MaxGhenis wants to merge 3 commits into
mainfrom
ledger-consumer-hardening

Conversation

@MaxGhenis

Copy link
Copy Markdown
Contributor

Summary

Finding #8 of the 2026-07-10 ledger architecture review ("artifact schema and profile integrity are nominal rather than end-to-end enforced"): the consumer artifact's stated integrity guarantees become enforced ones.

  • Row schema validation at build AND load. jsonschema becomes a runtime dependency; the pinned consumer_fact.v1 schema ships inside the wheel (policyengine_ledger/schemas/, byte-identity with docs/schemas/ enforced by test) and every row is validated on both build_consumer_artifact and load_consumer_artifact with no public opt-out. Duplicate aggregate_fact_key rows are rejected. The manifest records consumer_fact_schema_sha256; a manifest declaring an unknown schema hash is rejected loudly.
  • Profile hashes cover the real bytes and are verified on load. The build hashed profile_json without the trailing newline it wrote to disk, and the loader never checked profile hashes at all — dead, wrong integrity metadata. Build now hashes exact file bytes; load re-hashes every profile against the manifest. Pre-fix manifests (no schema-sha field) may match via the legacy pre-newline semantics, recorded explicitly on the loaded artifact (profile_hash_semantics) — never silently. Tampered profile bytes fail either way.
  • dimensions selector support. The two shipped uk_firms cross-tab targets (SIC × turnover band, SIC × employment band) used a selector key the resolver vocabulary didn't have, so they could never resolve strictly. dimensions is now a supported selector: strict, order-insensitive exact match on the row's dimension-variable set. A fixtures test resolves both targets through resolve_profile_targets strict mode, and a packaged-profiles gate asserts every shipped profile's selectors use only supported keys.

Ledger Governance

  • Role: ledger-contract-maintainer scope (policyengine_ledger/**, docs/schemas/** untouched except packaging a byte-identical copy; no source-package or fact-value changes). The two uk_firms profile targets are NOT edited — the resolver vocabulary was extended to support their existing, correct intent.
  • Deterministic checks: full CI (ruff, pytest incl. new adversarial tests, source-DB build, wheel build + wheel-smoke import of the packaged schema).
  • LLM-judge reviews (ledger-contract, ledger-boundary): not run — this PR was prepared as part of the cross-institution hardening sprint at Max's direction; flagging here explicitly rather than claiming verdicts. No Populace-side logic enters Ledger (boundary rules honored: validation/identity only).

Tests

uv sync --locked --all-extras; uv run ruff check ledger policyengine_ledger db scripts tests clean; uv run pytest -q576 passed, 1 skipped (skip pre-existing). CI's DB build + wheel-smoke steps run locally, with the wheel verified to contain and load the packaged schema. Each new adversarial test was verified to FAIL against the pre-fix code and pass after: missing required field named in the error; unknown extra field rejected; unknown manifest schema-sha rejected; tampered profile byte detected; legacy pre-newline hash accepted only via the explicit path; duplicate aggregate_fact_key rejected; unsupported selector key still fails loudly.

Pairs with PolicyEngine/populace#391 (consumer-side pins/validation/vendoring) and #86 (thesis-facts append gate).

🤖 Generated with Claude Code

MaxGhenis and others added 2 commits July 10, 2026 07:23
Ship the pinned consumer_fact.v1 schema inside the wheel as the single
source of truth for artifact validation. A new policyengine_ledger.schema
module loads the packaged copy and exposes the parsed schema, its sha256,
and a row validator that raises with a precise path, line, and JSON
location. A test pins the packaged copy byte-for-byte to docs/schemas, and
the wheel-smoke import test confirms the schema ships in the wheel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Schema-validate every fact row at build and load, always and with no public
opt-out, and reject rows with a duplicate aggregate_fact_key. Record the
schema sha256 in the manifest so a load rejects an unknown schema version
loudly; older manifests without the field still get full row validation.

Hash each profile over the exact bytes written, including the trailing
newline, and re-verify every profile file against the manifest on load. A
pre-fix manifest whose hash omitted the newline matches only through an
explicit legacy_profile_hash path, recorded per profile on the loaded
artifact; tampered profile bytes never match and fail the load.

Add a dimensions selector that matches a row's dimension identity as an
order-insensitive exact set, so the ONS SIC-by-turnover and SIC-by-employment
firm cross-tab targets resolve strictly. Adversarial tests exercise each
guard, a packaged-profile gate rejects any profile whose selector uses an
unsupported key, and a fixtures test resolves both cross-tab targets end to
end through the artifact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sol round-2 finding 7. Consumer artifact load was trusting declared
content; now it recomputes and cross-checks:

- Every fact row's aggregate_fact_key is recomputed from the row's own
  component keys and raw aggregation/period/geography/entity/assertion via
  the shared _hash_key; a declared key that does not hash the row is
  rejected. Schema validation only checks key SYNTAX, so a syntactically
  valid all-zero identity key previously loaded clean.
- Rows parse with a parse_constant that rejects NaN/Infinity/-Infinity, and
  every numeric value (nested included) must be finite — Python's default
  json.loads accepts those non-JSON tokens.
- The manifest's fact_row_count and each profile's target_count are compared
  with the actually-loaded rows/targets and a mismatch is rejected.

Full recomputation of the leaf component keys from raw fields (rebuilding
an AggregateFact from the projected consumer row) is the remaining depth
for internally-consistent forged keys; the top-level recompute closes the
demonstrated all-zero forgery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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