Skip to content

Add execution receipt schema v1 for published results - #571

Draft
MaxGhenis wants to merge 17 commits into
mainfrom
w4/receipt-schema-v1
Draft

MaxGhenis wants to merge 17 commits into
mainfrom
w4/receipt-schema-v1

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #570

What this adds

policyengine.provenance.receipt.ExecutionReceipt (schema v1) is a strict, immutable, JSON round-trippable record of exactly what produced a UK result. It is the contract the UK Budget dashboard (W5) uses for per-measure receipts, and the scorecard (W6) binds to it. Both need it by 14 October.

Field Contents
wrapper, model, core Versions of policyengine, policyengine-uk and policyengine-core, read from the installed distributions. The schema fixes the names as consts. A wheel SHA-256 appears only with evidence: either wheel_paths= (the builder checks the wheel's members against its RECORD one to one, then the installed files against the wheel), or the installer's PEP 610 archive hash.
data Release, build, the build-time model version, dataset name, URI and revision, and the SHA-256 hashed from the bytes simulated.
certification_status, certificate certified only when the bundle certificate covers the installed model and core, those dataset bytes and name, and the running wrapper. Otherwise the receipt is uncertified. The certificate digest is RFC 8785 over data_releases.uk.
reform, baseline Both required. Each is current_law or a digest of {representation, payload}, never normalized across representations. A Scenario with a module-level simulation_modifier is bound by code_revision (see below).
fiscal_year uk_fiscal_year: year Y is the fiscal year starting 6 April Y (2027-28). It is a const in v1.
outputs_sha256, runtime An optional output digest, and the runtime context, which is part of no digest.

Also added:

  • provenance.canonical (RFC 8785);
  • provenance.wheels;
  • the builders build_execution_receipt and build_execution_receipt_for_microsimulation;
  • verify_certificate;
  • the JSON Schema shipped in the wheel, with x-policyengine-rules and x-policyengine-digests;
  • docs/receipts.md.

Invariants (each tested)

  1. No false certification. A certificate's model, core, dataset digest and wrapper equal what ran. The builder certifies only when the bundle covers all of them.
  2. Verification is exact. verify_certificate(receipt, bundle) rebuilds the certificate and the data identity from the bundle record, using the builder's own derivation. Any consistent forgery of a certified member is reported, even one that keeps the genuine record digest. Tests cover each of 16 single-field forgeries, Hypothesis-generated combinations, and a builder/verifier differential over bundle edits. certificate_source is the producer's statement, not evidence.
  3. Digest separation. The execution identity depends only on wrapper, model, core, data and certificate content. The receipt digest adds reform, baseline, years and outputs, never runtime context or labels (Hypothesis).
  4. Strict, canonical digests.
    • Nothing is stringified: sets, Decimal, arrays, NaN, datetimes, longdouble and deep nesting are refused with ReceiptError.
    • Member order and null-versus-omitted don't change a digest.
    • Output matches the rfc8785 package (differential) and the W6 golden fixtures byte for byte.
  5. A modifier digest names only code that ran.
    • The digest covers the function's source, its module's source and its defaults, plus code_revision with a full commit sha.
    • It is identical across checkout paths and on Python 3.11–3.14.
    • The module's source as read must compile to the loaded code for every module-scope function, and module-scope literal constants must still hold their values. Edits after import and constants reassigned at run time are refused.
    • Closures, lambdas, methods, partials and non-Scenario objects are refused.
  6. Wheel evidence is real. A junk zip carrying a copied RECORD fails. Only launchers named in entry_points.txt are exempt, and stray .pyc or INSTALLER files fail. The real policyengine-core 3.33.0 install verifies: 199 members and 199 files.
  7. Model and schema agree (differential): both accept every generated valid receipt and reject the same malformed ones.

Review history

Independent adversarial review by an Opus 5.5 agent; the Codex lanes were unavailable. Each round executed reproductions and mutation runs.

  • Round 1: 13 findings, fixed in 2d4cb9a.
  • Round 2: 9 findings, fixed in 7df93cf. These covered certificate forgery, path-dependent modifier digests, wheel launchers and contents, caller-supplied metadata, non-Scenario inputs, unbound defaults, longdouble and recursion, and schema strictness.
  • Round 3: all nine round-2 findings were confirmed fixed. Its four new items are fixed in 5ec9bbf: module edits after import, seven mutation gaps, refusal messages and exception types.
  • Round 4: in progress at 402900a.

Breaking against earlier heads (v1 is unreleased):

  • metadata_module= is removed;
  • release_manifest_sha256 is removed from data;
  • the fiscal convention is UK-only;
  • the package-role models are ModelIdentity and CoreIdentity.

Re-vendor from this head.

Checks run locally

  • 202 receipt, wheel and canonical tests pass; the full suite passes (1,450 passed at 7df93cf).
  • ruff (CI version) and mypy on the new modules are clean.
  • generate_receipt_schemas.py --check passes, and the schema output is identical on pydantic 2.10.6 and 2.14.0.

axiom: n/a: provenance/infrastructure contract, no policy change.

🤖 Generated with Claude Code

A receipt names exactly what produced a published result. It records the
installed wrapper, model and core versions; the data release and the
SHA-256 of the simulated dataset bytes; the certificate digest, attached
only when it covers that model and those bytes; reform and baseline digests
bound to their representation; and the fiscal-year convention. Digests use
RFC 8785 canonical JSON, so the dashboard and scorecard can recompute them.
The JSON Schema ships in the wheel.

Fixes #570

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 4 commits October 11, 2026 15:44
…ificate

Independent review of #571 found ways a receipt could claim more than
what ran. Reform digests no longer collide for Scenario.from_reform
closures, lambdas, partials or bound methods: those are refused, and a
module-level modifier is bound by source, bytecode and revision. Digests
are over strict JSON, so numpy scalars, sets, Decimals and colliding keys
are converted exactly or refused instead of stringified. canonical_json
writes numpy floats and int subclasses as their values. Certification now
requires the installed core version, records whether the certificate was
packaged or caller-supplied, and verify_certificate checks it against a
published manifest. Wheel hashes are recorded only when the builder
verified the installed files against the wheel RECORD. The execution
identity no longer depends on how the builder was called, reform and
baseline are required, models parse strictly, overlay datasets get
uncertified receipts, and v1 covers UK results.

Refs #570

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- verify_certificate rebuilds the certificate from the bundle record with
  the builder's own derivation and compares every identity member plus the
  whole data identity; a forged receipt that keeps the genuine record digest
  is now reported. The "or require certificate_source packaged" rule is gone.
- Modifier identity no longer digests marshalled bytecode or the Python
  version, so the digest is the same in any checkout. The source is
  recompiled and compared with the loaded code (ignoring file and line
  numbers), defaults and the module source are bound, and code_revision
  needs a full commit sha.
- Wheel verification hashes every archive member against the wheel's own
  RECORD (one to one) and exempts only the launchers an installer generates
  for the wheel's declared console/GUI scripts; stray .pyc and INSTALLER
  files outside their installer locations fail.
- The public metadata_module hook is removed; versions always come from
  importlib.metadata.
- scenario_reform_identity refuses anything that is not a Scenario.
- strict_json_value refuses numpy longdouble and deep nesting with
  ReceiptError; content_sha256 raises ReceiptError for unsafe integers.
- The schema fixes package names and the UK fiscal-year convention as
  consts, and drops the never-populated release_manifest_sha256.

Refs #570

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Before binding a modifier, the module's source as read now must match
  what was imported: every module-scope function in that file compiles to
  its loaded code, and every module-scope name bound once to a literal
  still holds that value. Edited helpers, edited constants and constants
  reassigned at run time are refused instead of named by the digest.
- Functions defined in module-level blocks bind (source is dedented before
  compiling); methods are refused with a clear reason.
- Unreadable wheels (not a zip, duplicate RECORD entries) raise
  ReceiptError from installed_package_identity.
- Tests now kill the round-3 surviving mutants: each forgery alone, the
  record-version and basis checks, operator-only and nested-code edits,
  forged function globals and duplicated wheel members.

Refs #570

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 3 commits October 11, 2026 22:51
- Wheel verification now checks bytecode: every __pycache__ file this
  interpreter would load for a verified source (any optimisation level,
  listed in RECORD or not, under sys.pycache_prefix) must compile from
  that source. Caches the interpreter would ignore are reported as inert.
  Launchers are exempt only when they exist in bin/Scripts outside the
  site directory.
- The microsimulation adapter requires the file to still hash to the
  SHA-256 verified at load time, and the receipt to name the dataset, URI,
  revision and package recorded then; unmanaged loads cannot be receipted.
- Data identity is resolved with the materializer's own resolver
  (overlays, per-dataset package and revision, release manifest revision),
  and a receipt never gives an artifact's name to other bytes.
- Modifier binding: helper defaults, block-scoped literals and
  comprehension scope are checked; every module function's defaults are
  bound with their types; foreign helper globals and conditionally
  defined functions with different code are refused.
- Only .whl direct-URL archive hashes are recorded as wheel_sha256.
- Masked, 0-d, complex, datetime and structured NumPy values are refused.
- Years must be integers and schema_version may not be a boolean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Receipt v1 gains a required supported_years: the years the installed
policyengine-uk projects the dataset to, from its own year, computed as
the qualification supported_years gate does (extend_single_year_dataset,
no formula run), cached per file bytes and model version and never taken
from the caller. A reported year outside it is unsupported (the
unsupported_years property), and the published rules say its results
must be shown as provisional or not at all. The certified 1.58.0
dataset projects to 2024-2030, so a 2031-32 figure on it is flagged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Bytecode: a cache with flags 2 is timestamp-based (importlib reads the
  check-source bit only for hash-based caches), unknown flag bits and
  unreadable caches are inert, and a cache reached through a link is
  checked once for each source it stands for.
- Code constants compare by exact type and float bits, item by item, so
  (1,) and (1.0,), True and 1, or 0.0 and -0.0 are different code.
- Reform and scenario digests keep nulls: Scenario.from_reform reads
  {"2027": None} unlike {}.
- Module binding: the module is compiled whole (a function compiled alone
  can compile differently, as math.copysign did on 3.13); a name the
  source binds only with def must still be that function or a wrapper of
  it (through __wrapped__); functions count as module-level by their code
  object's own qualified name, which functools.wraps cannot copy.
- Binding analysis follows lexical scopes: a lambda body is its own scope;
  a def's defaults, decorators and annotations bind in the module.
- Float defaults are encoded by their bits; NumPy booleans are refused as
  years and as schema_version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 12, 2026
- Compositions use the null-preserving payload digest of #571's round 5,
  as every reform digest now does (composition payloads hold no nulls,
  so existing composition digests are unchanged).
- Tests: a tuple and a list of members give the same composition, and an
  invalid phase is refused even when no member changes anything.
- Docs: a baseline shared by several members (parameter changes only)
  appears once, at the first sharing member's position, which cannot
  affect the run (from the dashboard's composer, workstream W5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 3 commits October 12, 2026 08:06
A module-level modifier that called a module-level functools.partial got
one digest whatever the partial's captured values were: W5's review of
uk-autumn-budget-dashboard-2026#23 edited PAYMENT.keywords["amount"] from
120 to 240 and paid twice as much under the same reform_sha256.

Every module global the modifier reads, directly or through functions
defined in its file (from the bytecode, nested code and decorator
wrappers included), must now be a literal constant (checked by value), a
function defined in the file (checked against the source), an imported
module or callable, a builtin or a name the import system sets. A partial
or other object built at import, a value computed from the environment, a
file or the clock, a class defined in the file, an imported value, a name
set on the module after import, and code that assigns module globals are
refused, naming the global. State in other modules stays bound by the
code revision or installed versions, as documented.

Invariant (property-tested): an identity is issued if and only if every
global the modifier reads binds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Float and complex code constants compare by their IEEE 754 bytes:
  float.hex() wrote both signs of NaN as "nan", so a cache with a NaN of
  the other sign passed wheel verification (H1).
- A def name must still be bound to the function of that name, not to
  another function of the same file that matches its own source (M2).
- A constant bound by unpacking is no longer a checked literal and is
  now refused when read, through the computed-globals rule (M3).
- Two defaults that are one mutable object (in any function of the
  module, nested, or a container holding itself) are refused: the value
  encoding cannot tell them from two equal objects (M4).
- Module literals compare exactly by type, float bits and key order, for
  every literal kind, so unedited bytes, set and complex constants bind
  (L5); a := in an evaluated module annotation unbinds a constant (L6).
- The reform digest's description says nulls are kept (L7).
- Tests kill the three round-6 mutation survivors: the exception table,
  unknown pyc header flags and the projection's first-year filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round 7 reproduced state a digest missed through channels the bytecode
scan could not see: globals()/vars()/getattr on the module, decorator
closures and defaults, conditional literals and imports, rebound imports,
function attributes and aliased constants. Rather than chase each
channel, the module holding a bound modifier now needs a shape whose
state its source fixes:

- At module level: only docstrings, pass, imports, one-name literal
  assignments (annotated or not), undecorated defs and a main guard with
  no else; each name bound once; no := or lambda in defaults or
  annotations (H/M3/M4).
- Every namespace name is one the source binds: literals (exact value),
  functions (code, no attributes), imports (still what the import binds,
  holding a module or a non-partial callable; star-imported names
  judged the same way) (M2/M4/M5).
- No two literal constants or defaults are one mutable object, and no
  code tells equal values apart by identity (is other than against
  None/True/False/.../type(...), or id()) (M6).
- globals(), vars(), locals(), exec(), eval(), compile() and
  __import__() are refused in what the modifier runs (M2).
- Bytecode constants compare with their sharing across the code tree, so
  a cache splitting ((1,), (1,))'s shared tuple fails verification (H1);
  1,689 caches Python wrote for six installed packages still match.
- Tests for class-body LOAD_NAME, class type-alias
  LOAD_FROM_DICT_OR_GLOBALS and DELETE_GLOBAL (L8); a class inside the
  modifier reads its own names (L7).

Tests that exercised the superseded per-binding analyses now assert the
shape refusals. The unbindable test modifiers moved to their own module.
Mutation screening: 18 of 18 mutants of the new checks are killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 12, 2026
- Compositions use the null-preserving payload digest of #571's round 5,
  as every reform digest now does (composition payloads hold no nulls,
  so existing composition digests are unchanged).
- Tests: a tuple and a list of members give the same composition, and an
  invalid phase is refused even when no member changes anything.
- Docs: a baseline shared by several members (parameter changes only)
  appears once, at the first sharing member's position, which cannot
  affect the run (from the dashboard's composer, workstream W5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Python 3.14 compiles annotations to module-level __annotate__
  functions; they are the compiler's, not defs, so an annotated constant
  and an annotated modifier no longer fail as "two definitions" (M5).
- A main guard never runs on import, so its body binds nothing (L6).
- Module-wide AST rules replace the bytecode read scan, which every name
  in the namespace being checked had made redundant: id, operator.is_
  and is_not, globals, vars, locals, exec, eval, compile, __import__ and
  breakpoint are refused by name however imported; importing builtins is
  refused; type may not be rebound, so `is type(...)` stays safe; global
  statements are refused; dunder attributes other than __init__/__name__
  and dunder names other than __name__/__file__ are introspection (M3,
  M4, M1/M2 reads).
- Each function's name, qualified name, docstring (as the compiler
  stores it), annotated names and builtins must be what its def made; the
  module's docstring and builtins too; only the import system's and the
  annotation machinery's dunders may sit in the namespace unbound (M1,
  M2). Annotations may only name types (no calls); dunder defs are
  refused.
- Regressions for the twelve round-8 survivors, including sharing into
  nested code and frozensets (skipped on Python 3.14, which merges equal
  constants in every code object, so a cache cannot split them there).

Mutation screening: 30 of 30 mutants of the round-8 checks are killed.
Tests: 412 pass on 3.13; on 3.14 all pass except the two that need
policyengine-uk's dependencies, absent from that local environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 12, 2026
- Compositions use the null-preserving payload digest of #571's round 5,
  as every reform digest now does (composition payloads hold no nulls,
  so existing composition digests are unchanged).
- Tests: a tuple and a list of members give the same composition, and an
  invalid phase is refused even when no member changes anything.
- Docs: a baseline shared by several members (parameter changes only)
  appears once, at the first sharing member's position, which cannot
  affect the run (from the dashboard's composer, workstream W5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Receipts refuse a run whose installed versions changed after loading
  (H1). policyengine records its version when imported, and each country
  model records its model, core and wrapper versions when it loads the
  country package (policyengine.provenance.loaded, first seen kept); both
  builders compare them with what is installed when the receipt is built,
  so a notebook that upgrades a package mid-session is told to restart.
- typing.get_type_hints, inspect.signature, getfullargspec and
  get_annotations are refused: annotation values are not bound, only
  which names carry one (M2).
- A generic function (def helper[T], Python 3.12+) is found inside the
  wrapper the compiler makes for its type parameters, so it binds and is
  still checked against its source (L3).
- Regressions for the nine round-9 survivors: every code_shape field,
  main-guard naming, identity-call operands and the installed RECORD
  comparison (L4).

Mutation screening: 19 of 19 mutants of the round-9 changes are killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 13, 2026
- Compositions use the null-preserving payload digest of #571's round 5,
  as every reform digest now does (composition payloads hold no nulls,
  so existing composition digests are unchanged).
- Tests: a tuple and a list of members give the same composition, and an
  invalid phase is refused even when no member changes anything.
- Docs: a baseline shared by several members (parameter changes only)
  appears once, at the first sharing member's position, which cannot
  affect the run (from the dashboard's composer, workstream W5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 4 commits October 14, 2026 05:56
Round-10 review of #571:
- M1: version drift no longer depends on import order. Besides the
  recorded-version comparison, a receipt is refused when the wrapper's,
  model's or core's install metadata was written after this process
  started (psutil process start time against the .dist-info files). A
  missing version record is allowed: an install that predates the
  process has one version for the whole process. Known false refusal:
  installing and importing a package in the same process.
- M2: a scenario identity keeps a process-local re-check handle, outside
  pydantic fields, equality, dumps and digests. Receipt builders
  recompute reform and baseline identities and refuse if the modifier's
  module changed since the identity was computed. Loaded identities have
  no handle and are not re-checked.
- M3: PackageIdentity gains optional install_origin (archive, directory,
  editable, vcs; None for an index install) from PEP 610 direct_url.json.
  A receipt is certified only if no package is a directory, editable or
  VCS install without installed_record_verified evidence; the model
  validator, CROSS_FIELD_RULES and verify_certificate enforce it.
  Malformed direct_url.json is refused.
- L1: from_callable is refused; getattr, hasattr, setattr and delattr
  need a literal, non-dunder name (except __init__ and __name__).
- L2: tests kill review mutants A, B, C, G, H, N, O and V.
- L3: literal arithmetic, TYPE_CHECKING blocks, type aliases and
  *args: *Ts are documented as refused, each with a pinned test.

Invariants: identity equality, dumps, schema and digests do not depend on
the re-check handle; certified implies no directory, editable or VCS
package without verified wheel evidence; a receipt is refused if a named
package's install metadata postdates the process.

Co-Authored-By: GPT-6.1 Sol via Subfleet <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion

Round-11 review of #571:
- M1: every annotation a module's functions hold must be what its source
  annotation names (identity for named objects; equality for subscripted
  generics and unions; under future annotations, the compiler's own
  string). An edited annotation that changed singledispatch dispatch is
  refused, and both receipt builders surface the refusal through the
  identity re-check.
- M2: the loaded policyengine, policyengine_uk and policyengine_core
  packages must come from the distribution importlib.metadata selected:
  for index, archive and VCS installs the loaded __init__.py is the file
  RECORD lists; for directory and editable installs it lies under the
  PEP 610 directory. A checkout earlier on sys.path than the installed
  release is refused.
- L1: a wheel's RECORD must list itself with empty hash and size; RECORD.jws
  and RECORD.p7s are the spec's only exceptions.

Co-Authored-By: GPT-6.1 Sol via Subfleet <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI installs this checkout editable and then installs a released
policyengine over it, so the tests run this checkout's code under
policyengine 6.3.4's metadata. The loaded-package check added in round 11
rightly refuses that, which failed every receipt-building test in CI.

An autouse fixture now takes the real policyengine, policyengine_uk and
policyengine_core modules as loaded from the distributions they name;
synthetic distributions and modules that tests place in sys.modules are
still checked by the real function. The test of the real editable install
is marked real_install, runs the check unchanged, and is skipped where the
installed policyengine is not an editable install of this checkout.

Reproduced locally with a venv that installs the checkout editable and then
policyengine 6.3.4: before, 86 failed and 66 errors; after, 670 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round-12 review of #571:
- M1: annotations are compared structurally and in order (origin, then
  each argument by the same rule), so Union[Decimal, float] and
  Union[float, Decimal] differ even when typing's cache hands back an
  earlier, differently ordered object.
- M2: a PEP 610 dir_info install without editable=true copied its files
  into site-packages, so the loaded __init__.py must equal its RECORD
  entry, as for index installs; directory containment applies only to
  editable installs. A plain "pip install ." is accepted and an edited
  checkout earlier on sys.path is refused.
- L1: unary minus or plus on a numeric constant is allowed inside
  annotations (Literal[-1, 0, 1]), nothing broader.
- L2: RECORD.jws and RECORD.p7s are accepted only as members of the
  verified wheel itself, and uv_build.json only inside the distribution's
  .dist-info directory.

Both install layouts pass: editable 786 passed, 1 skipped; this
checkout's src over a released policyengine 6.3.4, 785 passed, 2 skipped.

Co-Authored-By: GPT-6.1 Sol via Subfleet <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

Publish an execution receipt schema (v1) for published UK Budget results

1 participant