From c3799e7b51b8e248fb5044c460bdf4013da1a29e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Jul 2026 21:30:02 -0400 Subject: [PATCH 1/2] Add execution receipt provenance to API v1 --- docs/engineering/migration-contracts.md | 59 +- docs/generated/migration_contracts.json | 44 +- policyengine_api/constants.py | 8 + policyengine_api/endpoints/household.py | 108 ++- policyengine_api/execution_receipt.py | 854 ++++++++++++++++++ policyengine_api/openapi_spec.yaml | 338 +++++++ policyengine_api/services/economy_service.py | 223 ++++- pyproject.toml | 1 + scripts/export_migration_contracts.py | 12 +- scripts/guards/migration_contracts.py | 8 + tests/contract/registry.py | 31 +- .../test_app_v2_workflow_contracts.py | 3 + tests/contract/test_v1_route_contracts.py | 71 +- tests/fixtures/execution_receipt.py | 103 +++ tests/fixtures/execution_receipt_v1.json | 103 +++ .../test_calculate_deprecated_inputs.py | 32 + .../test_household_under_policy_receipt.py | 289 ++++++ tests/unit/services/test_economy_service.py | 377 +++++++- tests/unit/test_execution_receipt.py | 414 +++++++++ .../unit/test_migration_contract_artifacts.py | 2 +- uv.lock | 13 +- 21 files changed, 3020 insertions(+), 73 deletions(-) create mode 100644 policyengine_api/execution_receipt.py create mode 100644 tests/fixtures/execution_receipt.py create mode 100644 tests/fixtures/execution_receipt_v1.json create mode 100644 tests/unit/endpoints/test_household_under_policy_receipt.py create mode 100644 tests/unit/test_execution_receipt.py diff --git a/docs/engineering/migration-contracts.md b/docs/engineering/migration-contracts.md index 01a9e9e27..2bd646f08 100644 --- a/docs/engineering/migration-contracts.md +++ b/docs/engineering/migration-contracts.md @@ -8,7 +8,7 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | --- | ---: | | route group count | 10 | | workflow count | 7 | -| request count | 14 | +| request count | 15 | | db entity count | 6 | | sim flow count | 3 | @@ -34,67 +34,68 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi - Current contract: `api_v1_compatible` - Future owner: PR 10: Policy Migration -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `POST` | `/us/policy` | 201 | `policy` | `status`, `message`, `result.policy_id` | -| `GET` | `/us/policy/{policy_id}` | 200 | `policy` | `status`, `message`, `result` | -| `GET` | `/us/policies` | 200 | `policy` | `result` | +| Method | Path | Status | Route group | Stable response fields | Optional stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/us/policy` | 201 | `policy` | `status`, `message`, `result.policy_id` | | +| `GET` | `/us/policy/{policy_id}` | 200 | `policy` | `status`, `message`, `result` | | +| `GET` | `/us/policies` | 200 | `policy` | `result` | | ### `household_save_edit_read` - Current contract: `api_v1_compatible` - Future owner: PR 11: Household Migration -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `POST` | `/us/household` | 201 | `household` | `status`, `message`, `result.household_id` | -| `PUT` | `/us/household/{household_id}` | 200 | `household` | `status`, `message`, `result.household_id` | -| `GET` | `/us/household/{household_id}` | 200 | `household` | `status`, `message`, `result` | +| Method | Path | Status | Route group | Stable response fields | Optional stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/us/household` | 201 | `household` | `status`, `message`, `result.household_id` | | +| `PUT` | `/us/household/{household_id}` | 200 | `household` | `status`, `message`, `result.household_id` | | +| `GET` | `/us/household/{household_id}` | 200 | `household` | `status`, `message`, `result` | | ### `household_calculate` - Current contract: `api_v1_compatible` - Future owner: PR 13: Household Calculation Compute Cutover -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `POST` | `/us/calculate` | 200 | `household` | `status`, `message`, `result` | +| Method | Path | Status | Route group | Stable response fields | Optional stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/us/calculate` | 200 | `household` | `status`, `message`, `result` | `execution_receipt.schema_version`, `execution_receipt.resolved.runtime.name`, `execution_receipt.resolved.numeric_mode`, `execution_receipt.resolved.model.actual.version`, `execution_receipt.resolved.model.certified.version`, `execution_receipt.request_sha256`, `execution_receipt.result_sha256` | +| `GET` | `/us/household/{household_id}/policy/{policy_id}` | 200 | `household` | `status`, `message`, `result` | `execution_receipt.schema_version`, `execution_receipt.resolved.runtime.name`, `execution_receipt.resolved.numeric_mode`, `execution_receipt.resolved.model.actual.version`, `execution_receipt.resolved.model.certified.version`, `execution_receipt.request_sha256`, `execution_receipt.result_sha256` | ### `region_selection` - Current contract: `api_v1_compatible` - Future owner: PR 9: v2 Metadata, Regions, Datasets, Parameters, and Variables -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `GET` | `/us/metadata` | 200 | `metadata` | `status`, `result.current_law_id`, `result.economy_options.region`, `result.economy_options.time_period` | -| `GET` | `/uk/metadata` | 200 | `metadata` | `status`, `result.current_law_id`, `result.economy_options.region`, `result.economy_options.time_period` | +| Method | Path | Status | Route group | Stable response fields | Optional stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `GET` | `/us/metadata` | 200 | `metadata` | `status`, `result.current_law_id`, `result.economy_options.region`, `result.economy_options.time_period` | | +| `GET` | `/uk/metadata` | 200 | `metadata` | `status`, `result.current_law_id`, `result.economy_options.region`, `result.economy_options.time_period` | | ### `simulation_submit_poll` - Current contract: `api_v1_compatible` - Future owner: PR 13: Household Calculation Compute Cutover -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `POST` | `/us/simulation` | 201 | `simulation` | `status`, `message`, `result.id`, `result.status` | -| `GET` | `/us/simulation/{simulation_id}` | 200 | `simulation` | `status`, `message`, `result` | +| Method | Path | Status | Route group | Stable response fields | Optional stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/us/simulation` | 201 | `simulation` | `status`, `message`, `result.id`, `result.status` | | +| `GET` | `/us/simulation/{simulation_id}` | 200 | `simulation` | `status`, `message`, `result` | | ### `report_create_poll` - Current contract: `api_v1_compatible` - Future owner: PR 14: Economy Simulation and Economic Impact Compute Cutover -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `POST` | `/us/report` | 201 | `report` | `status`, `message`, `result.id`, `result.status` | -| `GET` | `/us/report/{report_id}` | 200 | `report` | `status`, `message`, `result` | +| Method | Path | Status | Route group | Stable response fields | Optional stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/us/report` | 201 | `report` | `status`, `message`, `result.id`, `result.status` | | +| `GET` | `/us/report/{report_id}` | 200 | `report` | `status`, `message`, `result` | | ### `budget_window_submit_poll` - Current contract: `api_v1_compatible` - Future owner: PR 15: Budget-Window and Remaining Simulation API Migration -| Method | Path | Status | Route group | Stable response fields | -| --- | --- | ---: | --- | --- | -| `GET` | `/us/economy/{policy_id}/over/{baseline_policy_id}/budget-window?region=us&start_year=2026&window_size=1` | 200 | `economy` | `status`, `result.kind`, `progress`, `completed_years`, `computing_years`, `queued_years`, `error` | +| Method | Path | Status | Route group | Stable response fields | Optional stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `GET` | `/us/economy/{policy_id}/over/{baseline_policy_id}/budget-window?region=us&start_year=2026&window_size=1` | 200 | `economy` | `status`, `result.kind`, `progress`, `completed_years`, `computing_years`, `queued_years`, `error` | | diff --git a/docs/generated/migration_contracts.json b/docs/generated/migration_contracts.json index e24e03993..747e5ce32 100644 --- a/docs/generated/migration_contracts.json +++ b/docs/generated/migration_contracts.json @@ -1,7 +1,7 @@ { "metadata": { "db_entity_count": 6, - "request_count": 14, + "request_count": 15, "route_group_count": 10, "sim_flow_count": 3, "workflow_count": 7 @@ -103,6 +103,7 @@ { "expected_status": 201, "method": "POST", + "optional_stable_response_fields": [], "path": "/us/policy", "route_group": "policy", "stable_response_fields": [ @@ -114,6 +115,7 @@ { "expected_status": 200, "method": "GET", + "optional_stable_response_fields": [], "path": "/us/policy/{policy_id}", "route_group": "policy", "stable_response_fields": [ @@ -125,6 +127,7 @@ { "expected_status": 200, "method": "GET", + "optional_stable_response_fields": [], "path": "/us/policies", "route_group": "policy", "stable_response_fields": [ @@ -141,6 +144,7 @@ { "expected_status": 201, "method": "POST", + "optional_stable_response_fields": [], "path": "/us/household", "route_group": "household", "stable_response_fields": [ @@ -152,6 +156,7 @@ { "expected_status": 200, "method": "PUT", + "optional_stable_response_fields": [], "path": "/us/household/{household_id}", "route_group": "household", "stable_response_fields": [ @@ -163,6 +168,7 @@ { "expected_status": 200, "method": "GET", + "optional_stable_response_fields": [], "path": "/us/household/{household_id}", "route_group": "household", "stable_response_fields": [ @@ -181,6 +187,15 @@ { "expected_status": 200, "method": "POST", + "optional_stable_response_fields": [ + "execution_receipt.schema_version", + "execution_receipt.resolved.runtime.name", + "execution_receipt.resolved.numeric_mode", + "execution_receipt.resolved.model.actual.version", + "execution_receipt.resolved.model.certified.version", + "execution_receipt.request_sha256", + "execution_receipt.result_sha256" + ], "path": "/us/calculate", "route_group": "household", "stable_response_fields": [ @@ -188,6 +203,26 @@ "message", "result" ] + }, + { + "expected_status": 200, + "method": "GET", + "optional_stable_response_fields": [ + "execution_receipt.schema_version", + "execution_receipt.resolved.runtime.name", + "execution_receipt.resolved.numeric_mode", + "execution_receipt.resolved.model.actual.version", + "execution_receipt.resolved.model.certified.version", + "execution_receipt.request_sha256", + "execution_receipt.result_sha256" + ], + "path": "/us/household/{household_id}/policy/{policy_id}", + "route_group": "household", + "stable_response_fields": [ + "status", + "message", + "result" + ] } ] }, @@ -199,6 +234,7 @@ { "expected_status": 200, "method": "GET", + "optional_stable_response_fields": [], "path": "/us/metadata", "route_group": "metadata", "stable_response_fields": [ @@ -211,6 +247,7 @@ { "expected_status": 200, "method": "GET", + "optional_stable_response_fields": [], "path": "/uk/metadata", "route_group": "metadata", "stable_response_fields": [ @@ -230,6 +267,7 @@ { "expected_status": 201, "method": "POST", + "optional_stable_response_fields": [], "path": "/us/simulation", "route_group": "simulation", "stable_response_fields": [ @@ -242,6 +280,7 @@ { "expected_status": 200, "method": "GET", + "optional_stable_response_fields": [], "path": "/us/simulation/{simulation_id}", "route_group": "simulation", "stable_response_fields": [ @@ -260,6 +299,7 @@ { "expected_status": 201, "method": "POST", + "optional_stable_response_fields": [], "path": "/us/report", "route_group": "report", "stable_response_fields": [ @@ -272,6 +312,7 @@ { "expected_status": 200, "method": "GET", + "optional_stable_response_fields": [], "path": "/us/report/{report_id}", "route_group": "report", "stable_response_fields": [ @@ -290,6 +331,7 @@ { "expected_status": 200, "method": "GET", + "optional_stable_response_fields": [], "path": "/us/economy/{policy_id}/over/{baseline_policy_id}/budget-window?region=us&start_year=2026&window_size=1", "route_group": "economy", "stable_response_fields": [ diff --git a/policyengine_api/constants.py b/policyengine_api/constants.py index 745e8e97f..1fa65e4bb 100644 --- a/policyengine_api/constants.py +++ b/policyengine_api/constants.py @@ -1,5 +1,6 @@ import hashlib import json +from copy import deepcopy from datetime import datetime from importlib.metadata import distribution, distributions from pathlib import Path @@ -100,6 +101,13 @@ def get_bundle_data_release(country_id: str) -> dict: return release +def get_policyengine_bundle_manifest() -> dict | None: + """Return a defensive copy of the installed PolicyEngine bundle manifest.""" + if not isinstance(_policyengine_bundle, dict): + return None + return deepcopy(_policyengine_bundle) + + def get_bundle_default_dataset(country_id: str) -> str | None: release = get_bundle_data_release(country_id) default_dataset = release.get("default_dataset") diff --git a/policyengine_api/endpoints/household.py b/policyengine_api/endpoints/household.py index 24e14661d..96beb4b92 100644 --- a/policyengine_api/endpoints/household.py +++ b/policyengine_api/endpoints/household.py @@ -2,6 +2,10 @@ import json from flask import Response, request from policyengine_api.constants import COUNTRY_PACKAGE_VERSIONS +from policyengine_api.execution_receipt import ( + build_household_execution_receipt, + execution_receipt_matches_result, +) import logging from datetime import date from policyengine_api.utils.deprecated_inputs import drop_deprecated_inputs @@ -12,6 +16,70 @@ from policyengine_api.utils.payload_validators import validate_country +COMPUTED_HOUSEHOLD_ENVELOPE_KEY = "__policyengine_computed_household_cache__" +COMPUTED_HOUSEHOLD_ENVELOPE_VERSION = 1 + + +def _serialize_computed_household( + result: dict, + execution_receipt: dict | None, +) -> str: + """Serialize a versioned cache value without changing the legacy table.""" + return json.dumps( + { + COMPUTED_HOUSEHOLD_ENVELOPE_KEY: COMPUTED_HOUSEHOLD_ENVELOPE_VERSION, + "result": result, + "execution_receipt": execution_receipt, + } + ) + + +def _deserialize_computed_household(value: str) -> tuple[dict, dict | None]: + """Read new envelopes and legacy raw result rows without relabeling them.""" + payload = json.loads(value) + if not isinstance(payload, dict): + return payload, None + if payload.get( + COMPUTED_HOUSEHOLD_ENVELOPE_KEY + ) != COMPUTED_HOUSEHOLD_ENVELOPE_VERSION or not isinstance( + payload.get("result"), dict + ): + return payload, None + execution_receipt = payload.get("execution_receipt") + try: + receipt_matches_result = execution_receipt_matches_result( + execution_receipt, payload["result"] + ) + except Exception: + logging.exception( + "Failed to verify a cached household execution receipt; omitting provenance." + ) + receipt_matches_result = False + if not receipt_matches_result: + execution_receipt = None + return payload["result"], execution_receipt + + +def _build_household_execution_receipt_or_none( + *, + country_id: str, + request_payload: dict, + result: dict, +) -> dict | None: + """Keep optional provenance failures from failing a completed calculation.""" + try: + return build_household_execution_receipt( + country_id=country_id, + request_payload=request_payload, + result=result, + ) + except Exception: + logging.exception( + "Failed to build an execution receipt for a household calculation." + ) + return None + + def get_countries(): from policyengine_api.country import COUNTRIES @@ -124,13 +192,18 @@ def get_household_under_policy(country_id: str, household_id: str, policy_id: st computed_household_json=row["computed_household_json"], status=row["status"], ) - result["result"] = json.loads(result["computed_household_json"]) + result["result"], execution_receipt = _deserialize_computed_household( + result["computed_household_json"] + ) del result["computed_household_json"] - return dict( + response_body = dict( status="ok", message=None, result=result["result"], ) + if execution_receipt is not None: + response_body["execution_receipt"] = execution_receipt + return response_body # Retrieve from the household table @@ -209,7 +282,18 @@ def get_household_under_policy(country_id: str, household_id: str, policy_id: st mimetype="application/json", ) - # Store the result in the computed_household table + execution_receipt = _build_household_execution_receipt_or_none( + country_id=country_id, + request_payload={ + "household": household["household_json"], + "policy": policy["policy_json"], + }, + result=result, + ) + cached_result = _serialize_computed_household(result, execution_receipt) + + # Store the result and its original execution identity in the existing + # JSON cache column. Legacy raw-result rows remain readable above. try: local_database.query( @@ -218,15 +302,15 @@ def get_household_under_policy(country_id: str, household_id: str, policy_id: st country_id, household_id, policy_id, - json.dumps(result), + cached_result, api_version, ), ) except Exception: # Update the result if it already exists local_database.query( - "UPDATE computed_household SET computed_household_json = ? WHERE country_id = ? AND household_id = ? AND policy_id = ?", - (json.dumps(result), country_id, household_id, policy_id), + "UPDATE computed_household SET computed_household_json = ?, api_version = ? WHERE country_id = ? AND household_id = ? AND policy_id = ?", + (cached_result, api_version, country_id, household_id, policy_id), ) response_body = dict( @@ -234,6 +318,8 @@ def get_household_under_policy(country_id: str, household_id: str, policy_id: st message=None, result=result, ) + if execution_receipt is not None: + response_body["execution_receipt"] = execution_receipt warning_messages = [w.message for w in deprecated_inputs.warnings] if warning_messages: response_body["warnings"] = warning_messages @@ -291,6 +377,16 @@ def get_calculate(country_id: str, add_missing: bool = False) -> dict: message=None, result=result, ) + execution_receipt = _build_household_execution_receipt_or_none( + country_id=country_id, + request_payload={ + "household": household_json, + "policy": policy_json, + }, + result=result, + ) + if execution_receipt is not None: + response_body["execution_receipt"] = execution_receipt warning_messages = [w.message for w in deprecation_warnings] if warning_messages: diff --git a/policyengine_api/execution_receipt.py b/policyengine_api/execution_receipt.py new file mode 100644 index 000000000..a794d2df8 --- /dev/null +++ b/policyengine_api/execution_receipt.py @@ -0,0 +1,854 @@ +"""Serialize execution provenance using policyengine.py's receipt v1 contract. + +PolicyEngine API currently pins a policyengine.py release that predates the +``ExecutionReceipt`` Pydantic model. This module is a compatibility serializer, +not a competing schema: its exact output is pinned by the v1 contract fixture +under ``tests/fixtures/execution_receipt_v1.json``. +""" + +from collections.abc import Mapping +from datetime import datetime +import hashlib +import hmac +from importlib.metadata import PackageNotFoundError, version +from typing import Any + +import rfc8785 + +from policyengine_api.constants import ( + BUNDLED_COUNTRY_PACKAGE_NAMES, + get_policyengine_bundle_manifest, +) + + +EXECUTION_RECEIPT_SCHEMA_VERSION = 1 +SHA256_LENGTH = 64 +REQUESTED_ALIAS_FIELDS = ( + "engine", + "bundle", + "model", + "data", + "ruleset", + "population", + "numeric_mode", +) + +EXECUTION_RECEIPT_FIELDS = { + "schema_version", + "requested", + "resolved", + "run_id", + "created_at", + "request_sha256", + "result_sha256", +} +RESOLVED_EXECUTION_FIELDS = { + "runtime", + "numeric_mode", + "model", + "data", + "ruleset_artifact", + "population_artifact", + "certified_release", + "bundle_trace", +} +RESULT_PROVENANCE_FIELDS = { + "execution_receipt", + "policyengine_bundle", + "resolved_app_name", +} + + +def _optional_string(value: Any) -> str | None: + if value is None: + return None + text = str(value) + return text if text else None + + +def _is_optional_string(value: Any) -> bool: + return value is None or isinstance(value, str) + + +def _is_nonempty_string(value: Any) -> bool: + return isinstance(value, str) and bool(value) + + +def _is_optional_datetime(value: Any) -> bool: + if value is None: + return True + if not _is_nonempty_string(value) or "T" not in value: + return False + try: + datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return False + return True + + +def _is_sha256(value: Any, *, nullable: bool = True) -> bool: + if value is None: + return nullable + return ( + isinstance(value, str) + and len(value) == SHA256_LENGTH + and all(character in "0123456789abcdef" for character in value) + ) + + +def _has_only_fields(value: Mapping[str, Any], fields: set[str]) -> bool: + return all(isinstance(key, str) and key in fields for key in value) + + +def _is_package_version(value: Any) -> bool: + if not isinstance(value, Mapping): + return False + if not _has_only_fields(value, {"name", "version", "sha256", "wheel_url"}): + return False + return ( + _is_nonempty_string(value.get("name")) + and _is_nonempty_string(value.get("version")) + and _is_sha256(value.get("sha256")) + and _is_optional_string(value.get("wheel_url")) + ) + + +def _is_data_package_version(value: Any) -> bool: + if not isinstance(value, Mapping): + return False + fields = { + "name", + "version", + "sha256", + "wheel_url", + "repo_id", + "repo_type", + "release_manifest_path", + "release_manifest_revision", + } + if not _has_only_fields(value, fields): + return False + return ( + _is_nonempty_string(value.get("name")) + and _is_nonempty_string(value.get("version")) + and _is_sha256(value.get("sha256")) + and _is_optional_string(value.get("wheel_url")) + and _is_nonempty_string(value.get("repo_id")) + and _is_nonempty_string(value.get("repo_type")) + and _is_nonempty_string(value.get("release_manifest_path")) + and _is_optional_string(value.get("release_manifest_revision")) + ) + + +def _is_package_resolution(value: Any) -> bool: + if value is None: + return True + if not isinstance(value, Mapping): + return False + if not _has_only_fields(value, {"actual", "certified"}): + return False + actual = value.get("actual") + certified = value.get("certified") + if actual is None: + return False + return _is_package_version(actual) and ( + certified is None or _is_package_version(certified) + ) + + +def _is_artifact_identity(value: Any) -> bool: + if value is None: + return True + if not isinstance(value, Mapping): + return False + fields = {"name", "version", "uri", "revision", "sha256", "build_id"} + if not _has_only_fields(value, fields) or not _is_nonempty_string( + value.get("name") + ): + return False + if not _is_sha256(value.get("sha256")): + return False + identity_fields = ("version", "uri", "revision", "sha256", "build_id") + if not all(_is_optional_string(value.get(field)) for field in identity_fields): + return False + return any(value.get(field) for field in identity_fields) + + +def _is_runtime_identity(value: Any) -> bool: + if not isinstance(value, Mapping): + return False + if not _has_only_fields(value, {"name", "version", "git_sha", "artifact"}): + return False + return ( + _is_nonempty_string(value.get("name")) + and _is_nonempty_string(value.get("version")) + and _is_optional_string(value.get("git_sha")) + and _is_artifact_identity(value.get("artifact")) + ) + + +def _is_trace_reference(value: Any) -> bool: + if value is None: + return True + if not isinstance(value, Mapping): + return False + if not _has_only_fields( + value, {"composition_fingerprint", "sha256", "url", "name"} + ): + return False + return ( + _is_sha256(value.get("composition_fingerprint"), nullable=False) + and _is_sha256(value.get("sha256")) + and _is_optional_string(value.get("url")) + and _is_optional_string(value.get("name")) + ) + + +def _is_artifact_path_reference(value: Any) -> bool: + if not isinstance(value, Mapping): + return False + fields = { + "path", + "revision", + "sha256", + "metadata_sha256", + "repo_id", + "repo_type", + } + return ( + _has_only_fields(value, fields) + and _is_nonempty_string(value.get("path")) + and _is_optional_string(value.get("revision")) + and _is_sha256(value.get("sha256")) + and _is_sha256(value.get("metadata_sha256")) + and _is_optional_string(value.get("repo_id")) + and _is_optional_string(value.get("repo_type")) + ) + + +def _is_artifact_path_template(value: Any) -> bool: + return ( + isinstance(value, Mapping) + and _has_only_fields(value, {"path_template"}) + and _is_nonempty_string(value.get("path_template")) + ) + + +def _is_certified_data_artifact(value: Any) -> bool: + if value is None: + return True + if not isinstance(value, Mapping): + return False + fields = {"data_package", "dataset", "uri", "sha256", "build_id"} + data_package = value.get("data_package") + return ( + _has_only_fields(value, fields) + and (data_package is None or _is_package_version(data_package)) + and _is_nonempty_string(value.get("dataset")) + and _is_nonempty_string(value.get("uri")) + and _is_sha256(value.get("sha256")) + and _is_optional_string(value.get("build_id")) + ) + + +def _is_data_certification(value: Any) -> bool: + if value is None: + return True + if not isinstance(value, Mapping): + return False + fields = { + "compatibility_basis", + "certified_for_model_version", + "data_build_id", + "built_with_model_version", + "built_with_model_git_sha", + "data_build_fingerprint", + "certified_by", + } + return ( + _has_only_fields(value, fields) + and _is_nonempty_string(value.get("compatibility_basis")) + and _is_nonempty_string(value.get("certified_for_model_version")) + and all( + _is_optional_string(value.get(field)) + for field in fields - {"compatibility_basis", "certified_for_model_version"} + ) + ) + + +def _is_country_release_manifest(value: Any) -> bool: + if value is None: + return True + if not isinstance(value, Mapping): + return False + fields = { + "schema_version", + "bundle_id", + "published_at", + "country_id", + "policyengine_version", + "model_package", + "data_package", + "default_dataset", + "datasets", + "region_datasets", + "certified_data_artifact", + "certification", + } + if not _has_only_fields(value, fields): + return False + datasets = value.get("datasets") + region_datasets = value.get("region_datasets") + return ( + isinstance(value.get("schema_version"), int) + and not isinstance(value.get("schema_version"), bool) + and _is_optional_string(value.get("bundle_id")) + and _is_optional_string(value.get("published_at")) + and _is_nonempty_string(value.get("country_id")) + and _is_nonempty_string(value.get("policyengine_version")) + and _is_package_version(value.get("model_package")) + and _is_data_package_version(value.get("data_package")) + and _is_nonempty_string(value.get("default_dataset")) + and isinstance(datasets, Mapping) + and all( + _is_nonempty_string(name) and _is_artifact_path_reference(artifact) + for name, artifact in datasets.items() + ) + and isinstance(region_datasets, Mapping) + and all( + _is_nonempty_string(name) and _is_artifact_path_template(template) + for name, template in region_datasets.items() + ) + and _is_certified_data_artifact(value.get("certified_data_artifact")) + and _is_data_certification(value.get("certification")) + ) + + +def is_valid_execution_receipt(value: Any) -> bool: + """Return whether an untrusted worker value satisfies receipt v1. + + This deliberately validates engine-neutral fields instead of requiring a + PolicyEngine runtime name, so valid Axiom receipts pass through unchanged. + Certified release context is checked against the same fully specified + manifest shape published in OpenAPI. + """ + if not isinstance(value, Mapping): + return False + if not _has_only_fields(value, EXECUTION_RECEIPT_FIELDS): + return False + if ( + type(value.get("schema_version")) is not int + or value.get("schema_version") != EXECUTION_RECEIPT_SCHEMA_VERSION + ): + return False + + requested = value.get("requested") + if not isinstance(requested, Mapping): + return False + if not _has_only_fields(requested, set(REQUESTED_ALIAS_FIELDS)): + return False + if not all(_is_optional_string(requested.get(field)) for field in requested): + return False + + resolved = value.get("resolved") + if not isinstance(resolved, Mapping): + return False + if not _has_only_fields(resolved, RESOLVED_EXECUTION_FIELDS): + return False + if not _is_runtime_identity(resolved.get("runtime")): + return False + if not _is_nonempty_string(resolved.get("numeric_mode")): + return False + if not _is_package_resolution(resolved.get("model")): + return False + if not _is_package_resolution(resolved.get("data")): + return False + if not _is_artifact_identity(resolved.get("ruleset_artifact")): + return False + if not _is_artifact_identity(resolved.get("population_artifact")): + return False + certified_release = resolved.get("certified_release") + if not _is_country_release_manifest(certified_release): + return False + if not _is_trace_reference(resolved.get("bundle_trace")): + return False + + if certified_release is not None: + for component, release_field in ( + (resolved.get("model"), "model_package"), + (resolved.get("data"), "data_package"), + ): + if not isinstance(component, Mapping): + continue + certified = component.get("certified") + if certified is None: + continue + release_package = certified_release[release_field] + for field in ("name", "version", "sha256", "wheel_url"): + claimed_value = certified.get(field) + if claimed_value is not None and claimed_value != release_package.get( + field + ): + return False + + return ( + _is_optional_string(value.get("run_id")) + and _is_optional_datetime(value.get("created_at")) + and _is_sha256(value.get("request_sha256")) + and _is_sha256(value.get("result_sha256")) + ) + + +def _get_installed_package_version(package_name: str) -> str | None: + try: + return version(package_name) + except PackageNotFoundError: + return None + + +def _canonical_content_sha256(value: Mapping[str, Any]) -> str: + """Hash RFC 8785 JCS bytes for Rust/TypeScript/Python parity.""" + try: + canonical_bytes = rfc8785.dumps(value) + except rfc8785.FloatDomainError as exc: + raise ValueError( + "Execution receipt hashes require finite JSON numbers." + ) from exc + return hashlib.sha256(canonical_bytes).hexdigest() + + +def execution_result_sha256(result: Mapping[str, Any]) -> str: + """Hash the calculation result without provenance/enrichment siblings. + + Workers, caches, and this compatibility serializer all use this same target. + Adding an execution receipt, a PolicyEngine bundle, or a resolved app name + therefore does not invalidate a receipt for otherwise identical output. + """ + calculation_result = { + key: value + for key, value in result.items() + if key not in RESULT_PROVENANCE_FIELDS + } + return _canonical_content_sha256(calculation_result) + + +def execution_receipt_matches_result( + receipt: Any, + result: Mapping[str, Any], +) -> bool: + """Return whether a valid receipt cryptographically binds ``result``.""" + if not is_valid_execution_receipt(receipt): + return False + claimed_sha256 = receipt.get("result_sha256") + if not isinstance(claimed_sha256, str): + return False + return hmac.compare_digest(claimed_sha256, execution_result_sha256(result)) + + +def execution_receipt_runtime_name(receipt: Any) -> str | None: + """Read a claimed runtime name even from an unsupported receipt schema.""" + if not isinstance(receipt, Mapping): + return None + resolved = receipt.get("resolved") + if not isinstance(resolved, Mapping): + return None + runtime = resolved.get("runtime") + if not isinstance(runtime, Mapping): + return None + return _optional_string(runtime.get("name")) + + +def _package_version(value: Any) -> dict[str, str | None] | None: + if not isinstance(value, Mapping): + return None + name = _optional_string(value.get("name")) + package_version = _optional_string(value.get("version")) + if name is None or package_version is None: + return None + return { + "name": name, + "version": package_version, + "sha256": _optional_string(value.get("sha256")), + "wheel_url": _optional_string(value.get("wheel_url")), + } + + +def _actual_package(name: str | None, package_version: str | None) -> dict | None: + if name is None or package_version is None: + return None + return { + "name": name, + "version": package_version, + "sha256": None, + "wheel_url": None, + } + + +def _data_package_version(value: Any) -> dict | None: + package = _package_version(value) + if package is None or not isinstance(value, Mapping): + return None + repo_id = _optional_string(value.get("repo_id")) + if repo_id is None: + return None + return { + **package, + "repo_id": repo_id, + "repo_type": _optional_string(value.get("repo_type")) or "model", + "release_manifest_path": ( + _optional_string(value.get("release_manifest_path")) + or "release_manifest.json" + ), + "release_manifest_revision": _optional_string( + value.get("release_manifest_revision") + ), + } + + +def _artifact_path_reference(value: Any) -> dict | None: + if not isinstance(value, Mapping): + return None + path = _optional_string(value.get("path")) + if path is None: + return None + return { + "path": path, + "revision": _optional_string(value.get("revision")), + "sha256": _optional_string(value.get("sha256")), + "metadata_sha256": _optional_string(value.get("metadata_sha256")), + "repo_id": _optional_string(value.get("repo_id")), + "repo_type": _optional_string(value.get("repo_type")), + } + + +def _certified_data_artifact(value: Any) -> dict | None: + if not isinstance(value, Mapping): + return None + dataset = _optional_string(value.get("dataset")) + uri = _optional_string(value.get("uri")) + if dataset is None or uri is None: + return None + return { + "data_package": _package_version(value.get("data_package")), + "dataset": dataset, + "uri": uri, + "sha256": _optional_string(value.get("sha256")), + "build_id": _optional_string(value.get("build_id")), + } + + +def _certification(value: Any) -> dict | None: + if not isinstance(value, Mapping): + return None + compatibility_basis = _optional_string(value.get("compatibility_basis")) + certified_for_model_version = _optional_string( + value.get("certified_for_model_version") + ) + if compatibility_basis is None or certified_for_model_version is None: + return None + return { + "compatibility_basis": compatibility_basis, + "certified_for_model_version": certified_for_model_version, + "data_build_id": _optional_string(value.get("data_build_id")), + "built_with_model_version": _optional_string( + value.get("built_with_model_version") + ), + "built_with_model_git_sha": _optional_string( + value.get("built_with_model_git_sha") + ), + "data_build_fingerprint": _optional_string(value.get("data_build_fingerprint")), + "certified_by": _optional_string(value.get("certified_by")), + } + + +def _serialize_certified_release(value: Any) -> dict | None: + """Filter a bundled release to CountryReleaseManifest's declared fields.""" + if not isinstance(value, Mapping): + return None + model_package = _package_version(value.get("model_package")) + data_package = _data_package_version(value.get("data_package")) + country_id = _optional_string(value.get("country_id")) + policyengine_version = _optional_string(value.get("policyengine_version")) + default_dataset = _optional_string(value.get("default_dataset")) + if ( + model_package is None + or data_package is None + or country_id is None + or policyengine_version is None + or default_dataset is None + ): + return None + + datasets = {} + raw_datasets = value.get("datasets") + if isinstance(raw_datasets, Mapping): + for name, raw_artifact in raw_datasets.items(): + artifact = _artifact_path_reference(raw_artifact) + if artifact is not None: + datasets[str(name)] = artifact + + region_datasets = {} + raw_region_datasets = value.get("region_datasets") + if isinstance(raw_region_datasets, Mapping): + for name, raw_template in raw_region_datasets.items(): + if not isinstance(raw_template, Mapping): + continue + path_template = _optional_string(raw_template.get("path_template")) + if path_template is not None: + region_datasets[str(name)] = {"path_template": path_template} + + schema_version = value.get("schema_version", 1) + if not isinstance(schema_version, int): + return None + + serialized_release = { + "schema_version": schema_version, + "bundle_id": _optional_string(value.get("bundle_id")), + "published_at": _optional_string(value.get("published_at")), + "country_id": country_id, + "policyengine_version": policyengine_version, + "model_package": model_package, + "data_package": data_package, + "default_dataset": default_dataset, + "datasets": datasets, + "region_datasets": region_datasets, + "certified_data_artifact": _certified_data_artifact( + value.get("certified_data_artifact") + ), + "certification": _certification(value.get("certification")), + } + return ( + serialized_release if _is_country_release_manifest(serialized_release) else None + ) + + +def _release_for_runtime( + country_id: str, + policyengine_version: str | None, +) -> dict | None: + """Return the certified context only when it matches the selected bundle.""" + if policyengine_version is None: + return None + manifest = get_policyengine_bundle_manifest() + if not isinstance(manifest, Mapping): + return None + data_releases = manifest.get("data_releases") + if not isinstance(data_releases, Mapping): + return None + release = data_releases.get(country_id) + if not isinstance(release, Mapping): + return None + if _optional_string(release.get("policyengine_version")) != policyengine_version: + return None + return _serialize_certified_release(release) + + +def _requested_aliases(values: Mapping[str, Any] | None = None) -> dict: + values = values if isinstance(values, Mapping) else {} + return { + field: _optional_string(values.get(field)) for field in REQUESTED_ALIAS_FIELDS + } + + +def _runtime_identity( + *, + name: str, + runtime_version: str, + artifact_name: str | None = None, +) -> dict: + artifact = None + if artifact_name is not None: + artifact = { + "name": artifact_name, + "version": runtime_version, + "uri": None, + "revision": None, + "sha256": None, + "build_id": None, + } + return { + "name": name, + "version": runtime_version, + "git_sha": None, + "artifact": artifact, + } + + +def _artifact_revision(uri: str | None) -> str | None: + if uri is None or "@" not in uri: + return None + return uri.rsplit("@", 1)[1] or None + + +def _receipt( + *, + requested: Mapping[str, Any] | None, + runtime: dict, + model: dict | None, + data: dict | None, + population_artifact: dict | None, + certified_release: dict | None, + run_id: str | None, + request_payload: Mapping[str, Any] | None, + result: Mapping[str, Any], +) -> dict | None: + receipt = { + "schema_version": EXECUTION_RECEIPT_SCHEMA_VERSION, + "requested": _requested_aliases(requested), + "resolved": { + "runtime": runtime, + "numeric_mode": "numpy-native", + "model": model, + "data": data, + "ruleset_artifact": None, + "population_artifact": population_artifact, + "certified_release": certified_release, + "bundle_trace": None, + }, + "run_id": _optional_string(run_id), + "created_at": None, + "request_sha256": ( + _canonical_content_sha256(request_payload) + if request_payload is not None + else None + ), + "result_sha256": execution_result_sha256(result), + } + return receipt if is_valid_execution_receipt(receipt) else None + + +def build_household_execution_receipt( + *, + country_id: str, + request_payload: Mapping[str, Any], + result: Mapping[str, Any], +) -> dict | None: + """Build a receipt for an in-process household calculation.""" + runtime_version = _get_installed_package_version("policyengine-core") + if runtime_version is None: + return None + + bundle_version = _get_installed_package_version("policyengine") + certified_release = _release_for_runtime(country_id, bundle_version) + model_name = BUNDLED_COUNTRY_PACKAGE_NAMES.get(country_id) + actual_model = _actual_package( + model_name, + _get_installed_package_version(model_name) if model_name is not None else None, + ) + certified_model = ( + certified_release.get("model_package") + if certified_release is not None + else None + ) + model = None + if actual_model is not None: + model = {"actual": actual_model, "certified": certified_model} + + hashed_request = {"country_id": country_id, **dict(request_payload)} + return _receipt( + requested=None, + runtime=_runtime_identity( + name="policyengine-core", + runtime_version=runtime_version, + ), + model=model, + data=None, + population_artifact=None, + certified_release=certified_release, + run_id=None, + request_payload=hashed_request, + result=result, + ) + + +def build_economy_execution_receipt( + *, + country_id: str, + policyengine_bundle: Mapping[str, Any], + result: Mapping[str, Any], + requested: Mapping[str, Any] | None = None, + resolved_app_name: str | None = None, + run_id: str | None = None, +) -> dict | None: + """Build a receipt from the economy worker's resolved runtime bundle.""" + runtime_version = _optional_string(policyengine_bundle.get("policyengine_version")) + if runtime_version is None: + return None + + certified_release = _release_for_runtime(country_id, runtime_version) + model_name = BUNDLED_COUNTRY_PACKAGE_NAMES.get(country_id) + if model_name is None and certified_release is not None: + model_name = certified_release["model_package"]["name"] + actual_model = _actual_package( + model_name, + _optional_string(policyengine_bundle.get("model_version")), + ) + certified_model = ( + certified_release.get("model_package") + if certified_release is not None + else None + ) + model = None + if actual_model is not None: + model = {"actual": actual_model, "certified": certified_model} + + dataset = _optional_string(policyengine_bundle.get("dataset")) + data_version = _optional_string(policyengine_bundle.get("data_version")) + if dataset in (None, "default") or data_version is None or "://" not in dataset: + return None + + population_artifact = None + data = None + certified_artifact = ( + certified_release.get("certified_data_artifact") + if certified_release is not None + else None + ) + artifact_matches_certification = bool( + certified_artifact is not None + and dataset == certified_artifact.get("uri") + and data_version == certified_artifact.get("build_id") + ) + artifact_name = ( + certified_artifact["dataset"] if artifact_matches_certification else dataset + ) + candidate_population_artifact = { + "name": artifact_name, + "version": None, + "uri": dataset, + "revision": _artifact_revision(dataset), + "sha256": ( + certified_artifact.get("sha256") if artifact_matches_certification else None + ), + "build_id": data_version, + } + if _is_artifact_identity(candidate_population_artifact): + population_artifact = candidate_population_artifact + certified_data = ( + _package_version(certified_release.get("data_package")) + if certified_release is not None + else None + ) + actual_data = ( + certified_artifact.get("data_package") + if artifact_matches_certification + else None + ) + if actual_data is not None: + data = {"actual": actual_data, "certified": certified_data} + + return _receipt( + requested=requested, + runtime=_runtime_identity( + name="policyengine", + runtime_version=runtime_version, + artifact_name=_optional_string(resolved_app_name), + ), + model=model, + data=data, + population_artifact=population_artifact, + certified_release=certified_release, + run_id=run_id, + request_payload=None, + result=result, + ) diff --git a/policyengine_api/openapi_spec.yaml b/policyengine_api/openapi_spec.yaml index 805426bfd..8d83bde55 100644 --- a/policyengine_api/openapi_spec.yaml +++ b/policyengine_api/openapi_spec.yaml @@ -467,6 +467,8 @@ paths: nullable: true result: type: object + execution_receipt: + $ref: "#/components/schemas/ExecutionReceipt" 404: description: Household or policy not found. content: @@ -538,6 +540,8 @@ paths: nullable: true result: type: object + execution_receipt: + $ref: "#/components/schemas/ExecutionReceipt" 500: description: Error calculating household under policy. content: @@ -828,6 +832,11 @@ paths: nullable: true result: type: object + properties: + policyengine_bundle: + type: object + execution_receipt: + $ref: "#/components/schemas/ExecutionReceipt" 404: description: Invalid country ID. content: @@ -1098,3 +1107,332 @@ paths: type: object servers: type: array +components: + schemas: + ExecutionReceipt: + type: object + additionalProperties: false + required: + - schema_version + - requested + - resolved + properties: + schema_version: + type: integer + enum: [1] + requested: + $ref: "#/components/schemas/RequestedExecutionAliases" + resolved: + $ref: "#/components/schemas/ResolvedExecutionBundle" + run_id: + type: string + nullable: true + created_at: + type: string + format: date-time + nullable: true + request_sha256: + $ref: "#/components/schemas/NullableSha256" + result_sha256: + $ref: "#/components/schemas/NullableSha256" + RequestedExecutionAliases: + type: object + additionalProperties: false + properties: + engine: + type: string + nullable: true + bundle: + type: string + nullable: true + model: + type: string + nullable: true + data: + type: string + nullable: true + ruleset: + type: string + nullable: true + population: + type: string + nullable: true + numeric_mode: + type: string + nullable: true + ResolvedExecutionBundle: + type: object + additionalProperties: false + required: + - runtime + - numeric_mode + properties: + runtime: + $ref: "#/components/schemas/RuntimeIdentity" + numeric_mode: + type: string + model: + allOf: + - $ref: "#/components/schemas/PackageResolution" + nullable: true + data: + allOf: + - $ref: "#/components/schemas/PackageResolution" + nullable: true + ruleset_artifact: + allOf: + - $ref: "#/components/schemas/ArtifactIdentity" + nullable: true + population_artifact: + allOf: + - $ref: "#/components/schemas/ArtifactIdentity" + nullable: true + certified_release: + allOf: + - $ref: "#/components/schemas/CountryReleaseManifest" + nullable: true + bundle_trace: + allOf: + - $ref: "#/components/schemas/TraceReference" + nullable: true + RuntimeIdentity: + type: object + additionalProperties: false + required: + - name + - version + properties: + name: + type: string + version: + type: string + git_sha: + type: string + nullable: true + artifact: + allOf: + - $ref: "#/components/schemas/ArtifactIdentity" + nullable: true + PackageResolution: + type: object + additionalProperties: false + required: + - actual + properties: + actual: + $ref: "#/components/schemas/PackageVersion" + certified: + allOf: + - $ref: "#/components/schemas/PackageVersion" + nullable: true + PackageVersion: + type: object + additionalProperties: false + required: + - name + - version + properties: + name: + type: string + version: + type: string + sha256: + $ref: "#/components/schemas/NullableSha256" + wheel_url: + type: string + nullable: true + DataPackageVersion: + type: object + additionalProperties: false + required: + - name + - version + - repo_id + - repo_type + - release_manifest_path + properties: + name: + type: string + version: + type: string + sha256: + $ref: "#/components/schemas/NullableSha256" + wheel_url: + type: string + nullable: true + repo_id: + type: string + repo_type: + type: string + release_manifest_path: + type: string + release_manifest_revision: + type: string + nullable: true + ArtifactPathReference: + type: object + additionalProperties: false + required: + - path + properties: + path: + type: string + revision: + type: string + nullable: true + sha256: + $ref: "#/components/schemas/NullableSha256" + metadata_sha256: + $ref: "#/components/schemas/NullableSha256" + repo_id: + type: string + nullable: true + repo_type: + type: string + nullable: true + ArtifactPathTemplate: + type: object + additionalProperties: false + required: + - path_template + properties: + path_template: + type: string + CertifiedDataArtifact: + type: object + additionalProperties: false + required: + - dataset + - uri + properties: + data_package: + allOf: + - $ref: "#/components/schemas/PackageVersion" + nullable: true + dataset: + type: string + uri: + type: string + sha256: + $ref: "#/components/schemas/NullableSha256" + build_id: + type: string + nullable: true + DataCertification: + type: object + additionalProperties: false + required: + - compatibility_basis + - certified_for_model_version + properties: + compatibility_basis: + type: string + certified_for_model_version: + type: string + data_build_id: + type: string + nullable: true + built_with_model_version: + type: string + nullable: true + built_with_model_git_sha: + type: string + nullable: true + data_build_fingerprint: + type: string + nullable: true + certified_by: + type: string + nullable: true + CountryReleaseManifest: + type: object + additionalProperties: false + required: + - schema_version + - country_id + - policyengine_version + - model_package + - data_package + - default_dataset + - datasets + - region_datasets + properties: + schema_version: + type: integer + bundle_id: + type: string + nullable: true + published_at: + type: string + nullable: true + country_id: + type: string + policyengine_version: + type: string + model_package: + $ref: "#/components/schemas/PackageVersion" + data_package: + $ref: "#/components/schemas/DataPackageVersion" + default_dataset: + type: string + datasets: + type: object + additionalProperties: + $ref: "#/components/schemas/ArtifactPathReference" + region_datasets: + type: object + additionalProperties: + $ref: "#/components/schemas/ArtifactPathTemplate" + certified_data_artifact: + allOf: + - $ref: "#/components/schemas/CertifiedDataArtifact" + nullable: true + certification: + allOf: + - $ref: "#/components/schemas/DataCertification" + nullable: true + ArtifactIdentity: + type: object + additionalProperties: false + required: + - name + properties: + name: + type: string + version: + type: string + nullable: true + uri: + type: string + nullable: true + revision: + type: string + nullable: true + sha256: + $ref: "#/components/schemas/NullableSha256" + build_id: + type: string + nullable: true + TraceReference: + type: object + additionalProperties: false + required: + - composition_fingerprint + properties: + composition_fingerprint: + $ref: "#/components/schemas/Sha256" + sha256: + $ref: "#/components/schemas/NullableSha256" + url: + type: string + nullable: true + name: + type: string + nullable: true + Sha256: + type: string + pattern: "^[0-9a-f]{64}$" + NullableSha256: + type: string + pattern: "^[0-9a-f]{64}$" + nullable: true diff --git a/policyengine_api/services/economy_service.py b/policyengine_api/services/economy_service.py index 9142d3c2f..a72103fa8 100644 --- a/policyengine_api/services/economy_service.py +++ b/policyengine_api/services/economy_service.py @@ -24,6 +24,11 @@ ) from policyengine_api.data.places import validate_place_code from policyengine_api.gcp_logging import logger +from policyengine_api.execution_receipt import ( + build_economy_execution_receipt, + execution_receipt_matches_result, + execution_receipt_runtime_name, +) from policyengine_api.libs.simulation_api_modal import simulation_api_modal from policyengine_api.services.budget_window_cache import BudgetWindowCache from policyengine_api.services.policy_service import PolicyService @@ -99,6 +104,9 @@ class EconomicImpactSetupOptions(BaseModel): data_version: str | None = None runtime_app_name: str | None = None options_hash: str | None = None + requested_dataset: str | None = None + requested_model_version: str | None = None + requested_policyengine_version: str | None = None class EconomicImpactResult(BaseModel): @@ -578,6 +586,8 @@ def _build_economic_impact_setup_options( process_id: str = self._create_process_id() cache_version = get_economy_impact_cache_version(country_id, api_version) country_package_version = COUNTRY_PACKAGE_VERSIONS.get(country_id) + requested_dataset = dataset + requested_model_version = api_version resolved_dataset = self._canonical_dataset(country_id, dataset) resolved_data_version = self._extract_dataset_version(resolved_dataset) policyengine_version = ( @@ -608,6 +618,9 @@ def _build_economic_impact_setup_options( "data_version": resolved_data_version, "runtime_app_name": None, "options_hash": options_hash, + "requested_dataset": requested_dataset, + "requested_model_version": requested_model_version, + "requested_policyengine_version": None, } ) @@ -1092,48 +1105,206 @@ def _with_policyengine_bundle( execution: Optional[Any] = None, ) -> dict: result = result if isinstance(result, dict) else {} - cached_resolved_app_name = result.get("resolved_app_name") - use_setup_model_version = execution is not None or ( - isinstance(cached_resolved_app_name, str) and bool(cached_resolved_app_name) + response = {**result} + existing_receipt = result.get("execution_receipt") + runtime_name = execution_receipt_runtime_name(existing_receipt) + suppress_policyengine_synthesis = ( + runtime_name is not None + and runtime_name + not in { + "policyengine", + "policyengine-core", + } ) - bundle = { - "model_version": ( - setup_options.model_version if use_setup_model_version else None - ), - "policyengine_version": ( - setup_options.policyengine_version if use_setup_model_version else None - ), - "data_version": setup_options.data_version, - "dataset": setup_options.dataset, - } - if isinstance(result.get("policyengine_bundle"), dict): - for key, value in result["policyengine_bundle"].items(): - if value is not None: - bundle[key] = value + result_bundle = result.get("policyengine_bundle") + if not isinstance(result_bundle, dict): + response.pop("policyengine_bundle", None) + result_bundle = None execution_bundle = ( getattr(execution, "policyengine_bundle", None) if execution is not None else None ) - if isinstance(execution_bundle, dict): - for key, value in execution_bundle.items(): + if not isinstance(execution_bundle, dict): + execution_bundle = None + + bundle_sources = [ + bundle_source + for bundle_source in (result_bundle, execution_bundle) + if bundle_source is not None + ] + bundle: dict[str, Any] = {} + for bundle_source in bundle_sources: + for key, value in bundle_source.items(): if value is not None: bundle[key] = value - response = { - **result, - "policyengine_bundle": bundle, - } - resolved_app_name = None + result_app_name = result.get("resolved_app_name") + if not isinstance(result_app_name, str) or not result_app_name: + result_app_name = None + execution_app_name = None if execution is not None: maybe_resolved_app_name = getattr(execution, "resolved_app_name", None) if isinstance(maybe_resolved_app_name, str) and maybe_resolved_app_name: - resolved_app_name = maybe_resolved_app_name - if resolved_app_name is None: + execution_app_name = maybe_resolved_app_name + app_identity_conflict = ( + result_app_name is not None + and execution_app_name is not None + and result_app_name != execution_app_name + ) + resolved_app_name = execution_app_name or result_app_name + if resolved_app_name is None and bundle_sources: resolved_app_name = setup_options.runtime_app_name + + provenance_error = False + try: + receipt_matches_result = execution_receipt_matches_result( + existing_receipt, + result, + ) + if receipt_matches_result and not app_identity_conflict: + receipt_matches_result = self._execution_receipt_matches_context( + existing_receipt, + execution=execution, + policyengine_bundle=bundle, + resolved_app_name=resolved_app_name, + ) + else: + receipt_matches_result = False + except Exception as error: + provenance_error = True + receipt_matches_result = False + logger.log_struct( + { + "message": ( + "Failed to verify an economy execution receipt; returning " + "the calculation without a receipt." + ), + "error": str(error), + }, + severity="WARNING", + ) + if not receipt_matches_result: + response.pop("execution_receipt", None) if resolved_app_name: response["resolved_app_name"] = resolved_app_name + + # Never combine PolicyEngine metadata with an Axiom (or other + # non-PolicyEngine) runtime claim, even if the worker returned + # contradictory bundle enrichment alongside it. + if suppress_policyengine_synthesis: + response.pop("policyengine_bundle", None) + return response + if not bundle_sources: + return response + + response["policyengine_bundle"] = bundle + if receipt_matches_result: + return response + if provenance_error: + return response + + execution_run_id = ( + getattr(execution, "run_id", None) if execution is not None else None + ) + if not isinstance(execution_run_id, str): + execution_run_id = None + try: + execution_receipt = build_economy_execution_receipt( + country_id=setup_options.country_id, + policyengine_bundle=bundle, + result=response, + requested={ + "bundle": setup_options.requested_policyengine_version, + "model": setup_options.requested_model_version, + "population": setup_options.requested_dataset, + }, + resolved_app_name=resolved_app_name, + run_id=execution_run_id, + ) + except Exception as error: + logger.log_struct( + { + "message": ( + "Failed to build an economy execution receipt; returning " + "the calculation without a receipt." + ), + "error": str(error), + }, + severity="WARNING", + ) + execution_receipt = None + if execution_receipt is not None: + response["execution_receipt"] = execution_receipt return response + def _execution_receipt_matches_context( + self, + receipt: Any, + *, + execution: Optional[Any], + policyengine_bundle: dict[str, Any], + resolved_app_name: str | None, + ) -> bool: + """Cross-check a result-bound receipt against gateway execution facts.""" + if not isinstance(receipt, dict): + return False + + execution_run_id = ( + getattr(execution, "run_id", None) if execution is not None else None + ) + if ( + isinstance(execution_run_id, str) + and receipt.get("run_id") != execution_run_id + ): + return False + + resolved = receipt.get("resolved") + runtime = resolved.get("runtime") if isinstance(resolved, dict) else None + if not isinstance(runtime, dict): + return False + + if resolved_app_name is not None: + artifact = runtime.get("artifact") + if ( + not isinstance(artifact, dict) + or artifact.get("name") != resolved_app_name + ): + return False + + runtime_name = execution_receipt_runtime_name(receipt) + if runtime_name not in {"policyengine", "policyengine-core"}: + return True + + runtime_version = policyengine_bundle.get("policyengine_version") + if runtime_version is not None and runtime.get("version") != runtime_version: + return False + + model_version = policyengine_bundle.get("model_version") + if model_version is not None: + model = resolved.get("model") + actual_model = model.get("actual") if isinstance(model, dict) else None + if ( + not isinstance(actual_model, dict) + or actual_model.get("version") != model_version + ): + return False + + population = resolved.get("population_artifact") + dataset = policyengine_bundle.get("dataset") + if isinstance(dataset, str) and "://" in dataset: + if not isinstance(population, dict) or population.get("uri") != dataset: + return False + + data_version = policyengine_bundle.get("data_version") + if data_version is not None: + if ( + not isinstance(population, dict) + or population.get("build_id") != data_version + ): + return False + + return True + def _setup_region(self, country_id: str, region: str) -> str: """ Validate the region for the given country. diff --git a/pyproject.toml b/pyproject.toml index 43a2ba643..205e962a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ "pymysql", "python-dotenv", "redis", + "rfc8785==0.1.4", "rq", "sqlalchemy>=2,<3", "streamlit", diff --git a/scripts/export_migration_contracts.py b/scripts/export_migration_contracts.py index 218fe3f4c..044b2f53c 100644 --- a/scripts/export_migration_contracts.py +++ b/scripts/export_migration_contracts.py @@ -38,6 +38,9 @@ def build_payload() -> dict[str, Any]: for request in workflow.requests: row = asdict(request) row["stable_response_fields"] = list(row["stable_response_fields"]) + row["optional_stable_response_fields"] = list( + row["optional_stable_response_fields"] + ) requests.append(row) request_count += len(requests) workflows.append( @@ -119,18 +122,21 @@ def render_markdown(payload: dict[str, Any]) -> str: f"- Current contract: `{workflow['current_contract']}`", f"- Future owner: {workflow['future_owner_pr']}", "", - "| Method | Path | Status | Route group | Stable response fields |", - "| --- | --- | ---: | --- | --- |", + "| Method | Path | Status | Route group | Stable response fields | Optional stable response fields |", + "| --- | --- | ---: | --- | --- | --- |", ] ) for request in workflow["requests"]: fields = ", ".join( f"`{field}`" for field in request["stable_response_fields"] ) + optional_fields = ", ".join( + f"`{field}`" for field in request["optional_stable_response_fields"] + ) lines.append( f"| `{request['method']}` | `{request['path']}` | " f"{request['expected_status']} | `{request['route_group']}` | " - f"{fields} |" + f"{fields} | {optional_fields} |" ) lines.append("") diff --git a/scripts/guards/migration_contracts.py b/scripts/guards/migration_contracts.py index 2cdec9d31..3b7deb178 100644 --- a/scripts/guards/migration_contracts.py +++ b/scripts/guards/migration_contracts.py @@ -86,6 +86,14 @@ def _check_workflows(payload: dict[str, Any]) -> list[str]: ) if not request["stable_response_fields"]: violations.append(f"{context}: stable_response_fields is required") + overlap = set(request["stable_response_fields"]) & set( + request["optional_stable_response_fields"] + ) + if overlap: + violations.append( + f"{context}: response fields cannot be both required and optional: " + f"{sorted(overlap)}" + ) if not request["path"].startswith("/"): violations.append(f"{context}: path must start with /") if request["expected_status"] not in {200, 201, 202}: diff --git a/tests/contract/registry.py b/tests/contract/registry.py index 1ba70280d..23cb80a3a 100644 --- a/tests/contract/registry.py +++ b/tests/contract/registry.py @@ -10,6 +10,7 @@ class ContractRequest: expected_status: int stable_response_fields: tuple[str, ...] route_group: str + optional_stable_response_fields: tuple[str, ...] = () @dataclass(frozen=True) @@ -20,6 +21,17 @@ class WorkflowContract: requests: tuple[ContractRequest, ...] +EXECUTION_RECEIPT_RESPONSE_FIELDS = ( + "execution_receipt.schema_version", + "execution_receipt.resolved.runtime.name", + "execution_receipt.resolved.numeric_mode", + "execution_receipt.resolved.model.actual.version", + "execution_receipt.resolved.model.certified.version", + "execution_receipt.request_sha256", + "execution_receipt.result_sha256", +) + + APP_V2_WORKFLOW_CONTRACTS: tuple[WorkflowContract, ...] = ( WorkflowContract( name="policy_save_search", @@ -86,8 +98,25 @@ class WorkflowContract: method="POST", path="/us/calculate", expected_status=200, - stable_response_fields=("status", "message", "result"), + stable_response_fields=( + "status", + "message", + "result", + ), + route_group="household", + optional_stable_response_fields=EXECUTION_RECEIPT_RESPONSE_FIELDS, + ), + ContractRequest( + method="GET", + path="/us/household/{household_id}/policy/{policy_id}", + expected_status=200, + stable_response_fields=( + "status", + "message", + "result", + ), route_group="household", + optional_stable_response_fields=EXECUTION_RECEIPT_RESPONSE_FIELDS, ), ), ), diff --git a/tests/contract/test_app_v2_workflow_contracts.py b/tests/contract/test_app_v2_workflow_contracts.py index 742287943..f25d7de1c 100644 --- a/tests/contract/test_app_v2_workflow_contracts.py +++ b/tests/contract/test_app_v2_workflow_contracts.py @@ -23,4 +23,7 @@ def test_app_v2_workflow_contract_registry_is_complete(): assert request.path.startswith("/") assert request.expected_status in {200, 201, 202} assert request.stable_response_fields + assert not set(request.stable_response_fields) & set( + request.optional_stable_response_fields + ) assert request.route_group in ROUTE_GROUP_CONFIG_BY_NAME diff --git a/tests/contract/test_v1_route_contracts.py b/tests/contract/test_v1_route_contracts.py index 6b3407bbe..63cf7ea39 100644 --- a/tests/contract/test_v1_route_contracts.py +++ b/tests/contract/test_v1_route_contracts.py @@ -7,7 +7,10 @@ import pytest from flask import Flask, Response -from policyengine_api.endpoints.household import get_calculate +from policyengine_api.endpoints.household import ( + get_calculate, + get_household_under_policy, +) from policyengine_api.endpoints.policy import get_policy_search from policyengine_api.routes.household_routes import household_bp from policyengine_api.routes.policy_routes import policy_bp @@ -92,12 +95,15 @@ def _load_blueprint_with_fake_service( ): sentinel = object() original_service_module = sys.modules.get(service_module_name, sentinel) + original_route_module = sys.modules.get(route_module_name, sentinel) sys.modules.pop(route_module_name, None) sys.modules[service_module_name] = fake_service_module try: return getattr(importlib.import_module(route_module_name), blueprint_name) finally: sys.modules.pop(route_module_name, None) + if original_route_module is not sentinel: + sys.modules[route_module_name] = original_route_module if original_service_module is sentinel: sys.modules.pop(service_module_name, None) else: @@ -137,6 +143,10 @@ def create_contract_flask_app() -> Flask: app.register_blueprint(report_output_bp) app.route("//policies", methods=["GET"])(get_policy_search) app.route("//calculate", methods=["POST"])(get_calculate) + app.route( + "//household//policy/", + methods=["GET"], + )(get_household_under_policy) @app.route("/liveness-check") def liveness_check(): @@ -198,10 +208,34 @@ def _policy_search_rows(): ) +def _database_rows(sql: str, _parameters=None): + if "SELECT * FROM household WHERE" in sql: + row = { + "id": 456, + "country_id": "us", + "household_json": '{"people": {"you": {}}}', + } + return SimpleNamespace(fetchone=lambda: row) + if "SELECT * FROM policy WHERE" in sql: + row = { + "id": 22, + "country_id": "us", + "policy_json": "{}", + } + return SimpleNamespace(fetchone=lambda: row) + return _policy_search_rows() + + +def _local_database_rows(sql: str, _parameters=None): + if "SELECT * FROM computed_household" in sql: + return SimpleNamespace(fetchone=lambda: None) + return SimpleNamespace(fetchone=lambda: None) + + def _fake_country(): return SimpleNamespace( - metadata={}, - calculate=lambda household, policy: { + metadata={"variables": {}, "entities": {}}, + calculate=lambda household, policy, *_identifiers: { "people": {"you": {"age": {"2026": 40}}}, "policy": policy, }, @@ -225,7 +259,13 @@ def _patched_route_dependencies(): stack.enter_context( patch( "policyengine_api.endpoints.policy.database.query", - return_value=_policy_search_rows(), + side_effect=_database_rows, + ) + ) + stack.enter_context( + patch( + "policyengine_api.endpoints.household.local_database.query", + side_effect=_local_database_rows, ) ) stack.enter_context( @@ -341,6 +381,26 @@ def _expected_subset(contract: ContractRequest) -> dict: "status": "ok", "message": None, "result": {"people": {"you": {"age": {"2026": 40}}}}, + "execution_receipt": { + "schema_version": 1, + "resolved": { + "runtime": {"name": "policyengine-core"}, + "numeric_mode": "numpy-native", + }, + }, + } + if contract.path == "/us/household/{household_id}/policy/{policy_id}": + return { + "status": "ok", + "message": None, + "result": {"people": {"you": {"age": {"2026": 40}}}}, + "execution_receipt": { + "schema_version": 1, + "resolved": { + "runtime": {"name": "policyengine-core"}, + "numeric_mode": "numpy-native", + }, + }, } if contract.path in {"/us/metadata", "/uk/metadata"}: country_id = contract.path.strip("/").split("/")[0] @@ -406,6 +466,9 @@ def test_app_v2_api_v1_route_contract( assert_subset(payload, _expected_subset(contract)) for field_path in contract.stable_response_fields: assert_field_path_exists(payload, field_path) + for field_path in contract.optional_stable_response_fields: + if field_path.split(".", 1)[0] in payload: + assert_field_path_exists(payload, field_path) def test_health_routes_contract(contract_client: ContractClient): diff --git a/tests/fixtures/execution_receipt.py b/tests/fixtures/execution_receipt.py new file mode 100644 index 000000000..af453cf20 --- /dev/null +++ b/tests/fixtures/execution_receipt.py @@ -0,0 +1,103 @@ +"""Fixtures for the execution receipt compatibility contract.""" + +from copy import deepcopy + +import pytest + + +MODEL_SHA256 = "a" * 64 +DATASET_SHA256 = "b" * 64 +DATASET_BUILD_ID = "release-1" +DATASET_URI = "hf://policyengine/populace-us/populace_us_2024.h5@release-1" + +POLICYENGINE_BUNDLE_MANIFEST = { + "schema_version": 2, + "policyengine_version": "4.18.9", + "data_releases": { + "us": { + "schema_version": 1, + "bundle_id": "us-4.18.9", + "country_id": "us", + "policyengine_version": "4.18.9", + "model_package": { + "name": "policyengine-us", + "version": "1.752.2", + "sha256": MODEL_SHA256, + "wheel_url": "https://files.example/policyengine_us-1.752.2.whl", + }, + "data_package": { + "name": "populace-data", + "version": "0.1.0", + "repo_id": "policyengine/populace-us", + "repo_type": "dataset", + "release_manifest_path": "releases/release-1/release_manifest.json", + "release_manifest_revision": DATASET_BUILD_ID, + }, + "default_dataset": "populace_us_2024", + "datasets": { + "populace_us_2024": { + "path": "populace_us_2024.h5", + "revision": DATASET_BUILD_ID, + "sha256": DATASET_SHA256, + "repo_id": "policyengine/populace-us", + } + }, + "region_datasets": {"national": {"path_template": "populace_us_2024.h5"}}, + "certified_data_artifact": { + "data_package": { + "name": "populace-data", + "version": "0.1.0", + }, + "dataset": "populace_us_2024", + "uri": DATASET_URI, + "sha256": DATASET_SHA256, + "build_id": DATASET_BUILD_ID, + }, + "certification": { + "compatibility_basis": "built_with_model_package", + "certified_for_model_version": "1.752.2", + "data_build_id": DATASET_BUILD_ID, + "built_with_model_version": "1.752.2", + "certified_by": "policyengine.py bundle certification", + }, + } + }, +} + +HOUSEHOLD_REQUEST = { + "household": {"people": {"you": {}}}, + "policy": {}, +} +HOUSEHOLD_RESULT = {"people": {"you": {"age": {"2026": 40}}}} +ECONOMY_RESULT = { + "poverty_impact": {"baseline": 0.12, "reform": 0.10}, +} +ECONOMY_POLICYENGINE_BUNDLE = { + "model_version": "1.752.2", + "policyengine_version": "4.18.9", + "data_version": DATASET_BUILD_ID, + "dataset": DATASET_URI, +} + +INSTALLED_PACKAGE_VERSIONS = { + "policyengine": "4.18.9", + "policyengine-core": "3.28.0", + "policyengine-us": "1.752.2", +} + + +@pytest.fixture +def mock_execution_receipt_runtime(monkeypatch): + """Provide stable installed and certified package identities.""" + from policyengine_api import execution_receipt + + monkeypatch.setattr( + execution_receipt, + "get_policyengine_bundle_manifest", + lambda: deepcopy(POLICYENGINE_BUNDLE_MANIFEST), + ) + monkeypatch.setattr( + execution_receipt, + "_get_installed_package_version", + lambda package_name: INSTALLED_PACKAGE_VERSIONS.get(package_name), + ) diff --git a/tests/fixtures/execution_receipt_v1.json b/tests/fixtures/execution_receipt_v1.json new file mode 100644 index 000000000..cbb61fe51 --- /dev/null +++ b/tests/fixtures/execution_receipt_v1.json @@ -0,0 +1,103 @@ +{ + "schema_version": 1, + "requested": { + "engine": null, + "bundle": null, + "model": null, + "data": null, + "ruleset": null, + "population": null, + "numeric_mode": null + }, + "resolved": { + "runtime": { + "name": "policyengine-core", + "version": "3.28.0", + "git_sha": null, + "artifact": null + }, + "numeric_mode": "numpy-native", + "model": { + "actual": { + "name": "policyengine-us", + "version": "1.752.2", + "sha256": null, + "wheel_url": null + }, + "certified": { + "name": "policyengine-us", + "version": "1.752.2", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "wheel_url": "https://files.example/policyengine_us-1.752.2.whl" + } + }, + "data": null, + "ruleset_artifact": null, + "population_artifact": null, + "certified_release": { + "schema_version": 1, + "bundle_id": "us-4.18.9", + "published_at": null, + "country_id": "us", + "policyengine_version": "4.18.9", + "model_package": { + "name": "policyengine-us", + "version": "1.752.2", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "wheel_url": "https://files.example/policyengine_us-1.752.2.whl" + }, + "data_package": { + "name": "populace-data", + "version": "0.1.0", + "sha256": null, + "wheel_url": null, + "repo_id": "policyengine/populace-us", + "repo_type": "dataset", + "release_manifest_path": "releases/release-1/release_manifest.json", + "release_manifest_revision": "release-1" + }, + "default_dataset": "populace_us_2024", + "datasets": { + "populace_us_2024": { + "path": "populace_us_2024.h5", + "revision": "release-1", + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "metadata_sha256": null, + "repo_id": "policyengine/populace-us", + "repo_type": null + } + }, + "region_datasets": { + "national": { + "path_template": "populace_us_2024.h5" + } + }, + "certified_data_artifact": { + "data_package": { + "name": "populace-data", + "version": "0.1.0", + "sha256": null, + "wheel_url": null + }, + "dataset": "populace_us_2024", + "uri": "hf://policyengine/populace-us/populace_us_2024.h5@release-1", + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "build_id": "release-1" + }, + "certification": { + "compatibility_basis": "built_with_model_package", + "certified_for_model_version": "1.752.2", + "data_build_id": "release-1", + "built_with_model_version": "1.752.2", + "built_with_model_git_sha": null, + "data_build_fingerprint": null, + "certified_by": "policyengine.py bundle certification" + } + }, + "bundle_trace": null + }, + "run_id": null, + "created_at": null, + "request_sha256": "c70045c3e2fb9e44a191e61c43a40efd35d2fa0ce13f882ed0cb1ac069cc7a43", + "result_sha256": "590a28fe655915f57d7a092d9bdc3359f2156e350bfe372da4d7790cb88391eb" +} diff --git a/tests/unit/endpoints/test_calculate_deprecated_inputs.py b/tests/unit/endpoints/test_calculate_deprecated_inputs.py index 0d7799abd..550f932f3 100644 --- a/tests/unit/endpoints/test_calculate_deprecated_inputs.py +++ b/tests/unit/endpoints/test_calculate_deprecated_inputs.py @@ -146,6 +146,38 @@ def test__calculate__omits_warnings_without_deprecated_input(calculate_client): payload = response.get_json() assert "warnings" not in payload assert country.household == household + assert payload["execution_receipt"]["schema_version"] == 1 + assert payload["execution_receipt"]["resolved"]["runtime"]["name"] == ( + "policyengine-core" + ) + assert payload["result"] == {"household": household, "policy": {}} + + +def test__calculate__receipt_failure_does_not_fail_completed_result( + calculate_client, + monkeypatch, +): + # Given + client, _country = calculate_client + household = {"people": {"you": {"age": {"2025": 49}}}} + + def fail_receipt(**_kwargs): + raise ValueError("unsupported JCS value") + + monkeypatch.setattr( + household_endpoint, + "build_household_execution_receipt", + fail_receipt, + ) + + # When + response = client.post("/us/calculate", json={"household": household}) + + # Then + assert response.status_code == 200 + payload = response.get_json() + assert payload["result"] == {"household": household, "policy": {}} + assert "execution_receipt" not in payload def test__calculate__returns_400_for_unrecognized_household_variable( diff --git a/tests/unit/endpoints/test_household_under_policy_receipt.py b/tests/unit/endpoints/test_household_under_policy_receipt.py new file mode 100644 index 000000000..e6c48ad69 --- /dev/null +++ b/tests/unit/endpoints/test_household_under_policy_receipt.py @@ -0,0 +1,289 @@ +import json +from copy import deepcopy +from pathlib import Path +from unittest.mock import MagicMock + +from flask import Flask +import pytest + +from policyengine_api.endpoints import household as household_endpoint + + +RECEIPT_FIXTURE = Path(__file__).parents[2] / "fixtures" / "execution_receipt_v1.json" +HOUSEHOLD_RESULT = {"people": {"you": {"age": {"2026": 40}}}} + + +class DummyCountry: + metadata = {"variables": {}, "entities": {}} + + def __init__(self): + self.calculate_calls = 0 + + def calculate(self, household, policy, household_id, policy_id): + self.calculate_calls += 1 + return deepcopy(HOUSEHOLD_RESULT) + + +class QueryResult: + def __init__(self, row=None): + self.row = row + + def fetchone(self): + return self.row + + +@pytest.fixture +def household_policy_client(monkeypatch): + country = DummyCountry() + + def query(sql, _parameters=None): + if "SELECT * FROM computed_household" in sql: + return QueryResult() + if "FROM household WHERE" in sql: + return QueryResult( + { + "id": "456", + "country_id": "us", + "household_json": '{"people": {"you": {}}}', + } + ) + if "FROM policy WHERE" in sql: + return QueryResult( + { + "id": "22", + "country_id": "us", + "policy_json": "{}", + } + ) + return QueryResult() + + database_query = MagicMock(side_effect=query) + receipt = json.loads(RECEIPT_FIXTURE.read_text()) + + monkeypatch.setattr( + household_endpoint, + "get_countries", + lambda: {"us": country}, + ) + monkeypatch.setattr(household_endpoint.database, "query", database_query) + monkeypatch.setattr( + household_endpoint, + "build_household_execution_receipt", + lambda **_kwargs: deepcopy(receipt), + ) + monkeypatch.setattr( + household_endpoint, + "get_invalid_inputs_response", + lambda *_args: None, + ) + + app = Flask(__name__) + app.add_url_rule( + "//household//policy/", + "household_under_policy", + household_endpoint.get_household_under_policy, + methods=["GET"], + ) + return ( + app.test_client(), + country, + database_query, + receipt, + ) + + +def test__fresh_calculation__returns_and_caches_original_execution_receipt( + household_policy_client, +): + client, country, database_query, receipt = household_policy_client + + response = client.get("/us/household/456/policy/22") + + assert response.status_code == 200 + assert response.get_json() == { + "status": "ok", + "message": None, + "result": HOUSEHOLD_RESULT, + "execution_receipt": receipt, + } + assert country.calculate_calls == 1 + insert_call = next( + call for call in database_query.call_args_list if "INSERT INTO" in call.args[0] + ) + insert_parameters = insert_call.args[1] + cached_value = json.loads(insert_parameters[3]) + assert cached_value == { + household_endpoint.COMPUTED_HOUSEHOLD_ENVELOPE_KEY: 1, + "result": HOUSEHOLD_RESULT, + "execution_receipt": receipt, + } + + +def test__receipt_build_failure__returns_and_caches_result_without_receipt( + household_policy_client, + monkeypatch, +): + # Given + client, country, database_query, _receipt = household_policy_client + + def fail_receipt(**_kwargs): + raise ValueError("non-finite provenance value") + + monkeypatch.setattr( + household_endpoint, + "build_household_execution_receipt", + fail_receipt, + ) + + # When + response = client.get("/us/household/456/policy/22") + + # Then + assert response.status_code == 200 + assert response.get_json() == { + "status": "ok", + "message": None, + "result": HOUSEHOLD_RESULT, + } + assert country.calculate_calls == 1 + insert_call = next( + call for call in database_query.call_args_list if "INSERT INTO" in call.args[0] + ) + assert json.loads(insert_call.args[1][3])["execution_receipt"] is None + + +def test__cache_insert_conflict__updates_receipt_and_api_version( + household_policy_client, +): + # Given + client, _country, database_query, _receipt = household_policy_client + original_query = database_query.side_effect + + def fail_insert(sql, parameters=None): + if "INSERT INTO computed_household" in sql: + raise RuntimeError("duplicate cache key") + return original_query(sql, parameters) + + database_query.side_effect = fail_insert + + # When + response = client.get("/us/household/456/policy/22") + + # Then + assert response.status_code == 200 + update_call = next( + call + for call in database_query.call_args_list + if "UPDATE computed_household" in call.args[0] + ) + assert "computed_household_json = ?, api_version = ?" in update_call.args[0] + assert update_call.args[1][1] == household_endpoint.COUNTRY_PACKAGE_VERSIONS["us"] + + +def test__enveloped_cache_hit__returns_persisted_receipt_without_recalculation( + monkeypatch, +): + receipt = json.loads(RECEIPT_FIXTURE.read_text()) + cached_value = household_endpoint._serialize_computed_household( + HOUSEHOLD_RESULT, + receipt, + ) + query = MagicMock( + return_value=QueryResult( + { + "policy_id": "22", + "household_id": "456", + "country_id": "us", + "api_version": "1.0.0", + "computed_household_json": cached_value, + "status": "ok", + } + ) + ) + monkeypatch.setattr(household_endpoint.database, "query", query) + + response = household_endpoint.get_household_under_policy.__wrapped__( + "us", "456", "22" + ) + + assert response["result"] == HOUSEHOLD_RESULT + assert response["execution_receipt"] == receipt + assert query.call_count == 1 + + +def test__legacy_raw_cache_hit__does_not_fabricate_current_runtime_receipt( + monkeypatch, +): + query = MagicMock( + return_value=QueryResult( + { + "policy_id": "22", + "household_id": "456", + "country_id": "us", + "api_version": "1.0.0", + "computed_household_json": json.dumps(HOUSEHOLD_RESULT), + "status": "ok", + } + ) + ) + monkeypatch.setattr(household_endpoint.database, "query", query) + + response = household_endpoint.get_household_under_policy.__wrapped__( + "us", "456", "22" + ) + + assert response == { + "status": "ok", + "message": None, + "result": HOUSEHOLD_RESULT, + } + + +def test__cache_envelope_with_invalid_receipt__omits_receipt(): + cached_value = household_endpoint._serialize_computed_household( + HOUSEHOLD_RESULT, + {"schema_version": 1, "resolved": {}}, + ) + + result, receipt = household_endpoint._deserialize_computed_household(cached_value) + + assert result == HOUSEHOLD_RESULT + assert receipt is None + + +def test__cache_envelope_with_result_hash_mismatch__omits_receipt(): + # Given + receipt = json.loads(RECEIPT_FIXTURE.read_text()) + changed_result = {"people": {"you": {"age": {"2026": 41}}}} + cached_value = household_endpoint._serialize_computed_household( + changed_result, + receipt, + ) + + # When + result, restored_receipt = household_endpoint._deserialize_computed_household( + cached_value + ) + + # Then + assert result == changed_result + assert restored_receipt is None + + +def test__cache_receipt_verification_failure_returns_result_without_receipt(): + # Given + receipt = json.loads(RECEIPT_FIXTURE.read_text()) + non_jcs_result = {"value": float("nan")} + cached_value = household_endpoint._serialize_computed_household( + non_jcs_result, + receipt, + ) + + # When + result, restored_receipt = household_endpoint._deserialize_computed_household( + cached_value + ) + + # Then + assert result["value"] != result["value"] + assert restored_receipt is None diff --git a/tests/unit/services/test_economy_service.py b/tests/unit/services/test_economy_service.py index e2fa54032..381e5a206 100644 --- a/tests/unit/services/test_economy_service.py +++ b/tests/unit/services/test_economy_service.py @@ -4,6 +4,7 @@ import httpx import pytest +from policyengine_api.execution_receipt import execution_result_sha256 from policyengine_api.services.economy_service import ( BUDGET_WINDOW_MAX_END_YEAR, BUDGET_WINDOW_MAX_YEARS, @@ -32,6 +33,7 @@ MOCK_RESOLVED_APP_NAME, MOCK_RESOLVED_DATASET, MOCK_RUN_ID, + MOCK_RUNTIME_BUNDLE, MOCK_TIME_PERIOD, create_mock_budget_window_batch_execution, create_mock_reform_impact, @@ -78,7 +80,366 @@ def make_http_status_error( ) +def make_axiom_execution_receipt( + *, + result: dict | None = None, + schema_version: int = 1, +) -> dict: + bound_result = MOCK_REFORM_IMPACT_DATA if result is None else result + return { + "schema_version": schema_version, + "requested": {"engine": "axiom", "numeric_mode": "decimal"}, + "resolved": { + "runtime": { + "name": "axiom", + "version": "0.4.0", + "git_sha": None, + "artifact": None, + }, + "numeric_mode": "decimal", + "model": None, + "data": None, + "ruleset_artifact": { + "name": "us-federal", + "version": "2026.1", + "uri": None, + "revision": None, + "sha256": "c" * 64, + "build_id": None, + }, + "population_artifact": None, + "certified_release": None, + "bundle_trace": None, + }, + "run_id": "axiom-run-1", + "created_at": "2026-07-09T12:00:00Z", + "request_sha256": "d" * 64, + "result_sha256": execution_result_sha256(bound_result), + } + + +def make_policyengine_execution_receipt( + *, + run_id: str = MOCK_RUN_ID, + runtime_version: str = MOCK_POLICYENGINE_VERSION, + model_version: str = MOCK_MODEL_VERSION, + dataset: str = MOCK_RESOLVED_DATASET, + data_version: str = MOCK_DATA_VERSION, + resolved_app_name: str = MOCK_RESOLVED_APP_NAME, +) -> dict: + return { + "schema_version": 1, + "requested": {"engine": "policyengine"}, + "resolved": { + "runtime": { + "name": "policyengine", + "version": runtime_version, + "git_sha": None, + "artifact": { + "name": resolved_app_name, + "version": runtime_version, + }, + }, + "numeric_mode": "numpy-native", + "model": { + "actual": { + "name": "policyengine-us", + "version": model_version, + }, + "certified": None, + }, + "data": None, + "ruleset_artifact": None, + "population_artifact": { + "name": "populace_us_2024", + "uri": dataset, + "build_id": data_version, + }, + "certified_release": None, + "bundle_trace": None, + }, + "run_id": run_id, + "created_at": "2026-07-09T12:00:00Z", + "request_sha256": None, + "result_sha256": execution_result_sha256(MOCK_REFORM_IMPACT_DATA), + } + + class TestEconomyService: + class TestPolicyengineBundle: + @pytest.fixture + def setup_options(self): + return EconomicImpactSetupOptions( + process_id=MOCK_PROCESS_ID, + country_id=MOCK_COUNTRY_ID, + reform_policy_id=MOCK_POLICY_ID, + baseline_policy_id=MOCK_BASELINE_POLICY_ID, + region=MOCK_REGION, + dataset=MOCK_RESOLVED_DATASET, + time_period=MOCK_TIME_PERIOD, + options=MOCK_OPTIONS, + api_version=MOCK_API_VERSION, + target="general", + model_version=MOCK_MODEL_VERSION, + policyengine_version=MOCK_POLICYENGINE_VERSION, + data_version=MOCK_DATA_VERSION, + options_hash=MOCK_OPTIONS_HASH, + requested_dataset="default", + requested_model_version=MOCK_MODEL_VERSION, + ) + + def test__valid_axiom_receipt__is_preserved_without_replacement( + self, + setup_options, + ): + receipt = make_axiom_execution_receipt() + + with patch( + "policyengine_api.services.economy_service.build_economy_execution_receipt" + ) as build_receipt: + result = EconomyService()._with_policyengine_bundle( + { + **MOCK_REFORM_IMPACT_DATA, + "policyengine_bundle": MOCK_RUNTIME_BUNDLE, + "execution_receipt": receipt, + }, + setup_options, + ) + + assert result["execution_receipt"] is receipt + assert "policyengine_bundle" not in result + build_receipt.assert_not_called() + + def test__unknown_axiom_receipt__is_omitted_without_policyengine_relabeling( + self, + setup_options, + ): + # Given + receipt = make_axiom_execution_receipt(schema_version=2) + + # When + with patch( + "policyengine_api.services.economy_service.build_economy_execution_receipt" + ) as build_receipt: + result = EconomyService()._with_policyengine_bundle( + { + **MOCK_REFORM_IMPACT_DATA, + "execution_receipt": receipt, + }, + setup_options, + ) + + # Then + assert "execution_receipt" not in result + assert "policyengine_bundle" not in result + build_receipt.assert_not_called() + + def test__axiom_receipt_for_other_result__is_omitted_without_relabeling( + self, + setup_options, + ): + # Given + receipt = make_axiom_execution_receipt(result={"different": "result"}) + + # When + with patch( + "policyengine_api.services.economy_service.build_economy_execution_receipt" + ) as build_receipt: + result = EconomyService()._with_policyengine_bundle( + { + **MOCK_REFORM_IMPACT_DATA, + "execution_receipt": receipt, + }, + setup_options, + ) + + # Then + assert "execution_receipt" not in result + assert "policyengine_bundle" not in result + build_receipt.assert_not_called() + + def test__malformed_worker_receipt__is_replaced(self, setup_options): + replacement = make_axiom_execution_receipt() + + with patch( + "policyengine_api.services.economy_service.build_economy_execution_receipt", + return_value=replacement, + ) as build_receipt: + result = EconomyService()._with_policyengine_bundle( + { + **MOCK_REFORM_IMPACT_DATA, + "policyengine_bundle": MOCK_RUNTIME_BUNDLE, + "execution_receipt": { + "schema_version": 1, + "resolved": {}, + }, + }, + setup_options, + ) + + assert result["execution_receipt"] == replacement + build_receipt.assert_called_once() + assert build_receipt.call_args.kwargs["requested"] == { + "bundle": None, + "model": MOCK_MODEL_VERSION, + "population": "default", + } + + def test__receipt_from_another_execution__is_not_preserved(self, setup_options): + # Given + receipt = make_policyengine_execution_receipt(run_id="stale-run") + execution = MagicMock() + execution.run_id = MOCK_RUN_ID + execution.policyengine_bundle = MOCK_RUNTIME_BUNDLE + execution.resolved_app_name = MOCK_RESOLVED_APP_NAME + + # When + with patch( + "policyengine_api.services.economy_service.build_economy_execution_receipt", + return_value=None, + ) as build_receipt: + result = EconomyService()._with_policyengine_bundle( + { + **MOCK_REFORM_IMPACT_DATA, + "execution_receipt": receipt, + }, + setup_options, + execution, + ) + + # Then + assert "execution_receipt" not in result + build_receipt.assert_called_once() + + def test__receipt_with_stale_bundle_identities__is_not_preserved( + self, setup_options + ): + # Given + receipt = make_policyengine_execution_receipt( + runtime_version="0.0.1", + model_version="0.0.2", + dataset="hf://example/stale.h5@stale", + data_version="stale", + resolved_app_name="stale-app", + ) + + # When + with patch( + "policyengine_api.services.economy_service.build_economy_execution_receipt", + return_value=None, + ) as build_receipt: + result = EconomyService()._with_policyengine_bundle( + { + **MOCK_REFORM_IMPACT_DATA, + "policyengine_bundle": MOCK_RUNTIME_BUNDLE, + "resolved_app_name": MOCK_RESOLVED_APP_NAME, + "execution_receipt": receipt, + }, + setup_options, + ) + + # Then + assert "execution_receipt" not in result + build_receipt.assert_called_once() + + def test__execution_app_disagreement_replaces_stale_result_identity( + self, setup_options + ): + # Given + receipt = make_policyengine_execution_receipt(resolved_app_name="stale-app") + execution = MagicMock() + execution.run_id = MOCK_RUN_ID + execution.policyengine_bundle = MOCK_RUNTIME_BUNDLE + execution.resolved_app_name = MOCK_RESOLVED_APP_NAME + + # When + with patch( + "policyengine_api.services.economy_service.build_economy_execution_receipt", + return_value=None, + ) as build_receipt: + result = EconomyService()._with_policyengine_bundle( + { + **MOCK_REFORM_IMPACT_DATA, + "resolved_app_name": "stale-app", + "execution_receipt": receipt, + }, + setup_options, + execution, + ) + + # Then + assert "execution_receipt" not in result + assert result["resolved_app_name"] == MOCK_RESOLVED_APP_NAME + build_receipt.assert_called_once() + + def test__malformed_receipt_without_bundle_evidence__is_not_replaced( + self, + setup_options, + ): + # When + with patch( + "policyengine_api.services.economy_service.build_economy_execution_receipt" + ) as build_receipt: + result = EconomyService()._with_policyengine_bundle( + { + **MOCK_REFORM_IMPACT_DATA, + "execution_receipt": { + "schema_version": 1, + "resolved": {}, + }, + }, + setup_options, + ) + + # Then + assert "execution_receipt" not in result + assert "policyengine_bundle" not in result + build_receipt.assert_not_called() + + def test__receipt_build_failure__returns_legacy_result_and_bundle( + self, + setup_options, + ): + # When + with patch( + "policyengine_api.services.economy_service.build_economy_execution_receipt", + side_effect=ValueError("unsupported JCS value"), + ): + result = EconomyService()._with_policyengine_bundle( + { + **MOCK_REFORM_IMPACT_DATA, + "policyengine_bundle": MOCK_RUNTIME_BUNDLE, + }, + setup_options, + ) + + # Then + assert result["policyengine_bundle"] == MOCK_RUNTIME_BUNDLE + assert "execution_receipt" not in result + + def test__receipt_verification_failure__returns_legacy_axiom_result( + self, + setup_options, + ): + # Given + receipt = make_axiom_execution_receipt() + worker_result = { + **MOCK_REFORM_IMPACT_DATA, + "not_jcs": float("nan"), + "execution_receipt": receipt, + } + + # When + result = EconomyService()._with_policyengine_bundle( + worker_result, + setup_options, + ) + + # Then + assert result["not_jcs"] != result["not_jcs"] + assert "execution_receipt" not in result + assert "policyengine_bundle" not in result + class TestGetEconomicImpact: @pytest.fixture def economy_service(self): @@ -566,7 +927,7 @@ def test__given_legacy_cached_impact_and_runtime_lookup_fails__then_returns_cach result = economy_service.get_economic_impact(**base_params) assert result.status == ImpactStatus.OK - assert result.data["policyengine_bundle"]["model_version"] is None + assert "policyengine_bundle" not in result.data mock_simulation_api.run.assert_not_called() def test__given_legacy_computing_impact_without_resolved_app_name__then_reuses_execution( @@ -1395,6 +1756,7 @@ def test__given_succeeded_state__returns_completed_result( ): reform_impact = create_mock_reform_impact(status="computing") mock_execution = MagicMock() + mock_execution.policyengine_bundle = MOCK_RUNTIME_BUNDLE mock_simulation_api.get_execution_result.return_value = ( MOCK_REFORM_IMPACT_DATA ) @@ -1410,6 +1772,11 @@ def test__given_succeeded_state__returns_completed_result( "data_version": MOCK_DATA_VERSION, "dataset": MOCK_RESOLVED_DATASET, } + assert result.data["execution_receipt"]["schema_version"] == 1 + assert ( + result.data["execution_receipt"]["resolved"]["runtime"]["version"] + == MOCK_POLICYENGINE_VERSION + ) mock_reform_impacts_service.set_complete_reform_impact.assert_called_once() def test__given_failed_state__returns_error_result( @@ -1464,6 +1831,7 @@ def test__given_modal_complete_state__then_returns_completed_result( # Given reform_impact = create_mock_reform_impact(status="computing") mock_execution = MagicMock() + mock_execution.policyengine_bundle = MOCK_RUNTIME_BUNDLE mock_simulation_api.get_execution_result.return_value = ( MOCK_REFORM_IMPACT_DATA ) @@ -2056,9 +2424,16 @@ def test__given_bundle_default_dataset_name__canonicalizes_setup_identity(self): assert bundle_default_setup.dataset == "default" assert bundle_default_setup.data_version is None assert bundle_default_setup.options_hash == default_setup.options_hash + assert default_setup.requested_dataset == "default" + assert bundle_default_setup.requested_dataset == "populace_us_2024" + assert default_setup.requested_model_version == MOCK_API_VERSION assert deprecated_breakdown_setup.dataset == "default" assert deprecated_breakdown_setup.data_version is None assert deprecated_breakdown_setup.options_hash == default_setup.options_hash + assert ( + deprecated_breakdown_setup.requested_dataset + == "national-with-breakdowns" + ) def test__given_unknown_dataset__passes_through_legacy_designator(self): service = EconomyService() diff --git a/tests/unit/test_execution_receipt.py b/tests/unit/test_execution_receipt.py new file mode 100644 index 000000000..2e9ffca7b --- /dev/null +++ b/tests/unit/test_execution_receipt.py @@ -0,0 +1,414 @@ +import json +from pathlib import Path + +import pytest +import yaml + +from policyengine_api.execution_receipt import ( + build_economy_execution_receipt, + build_household_execution_receipt, + execution_receipt_matches_result, + execution_result_sha256, + is_valid_execution_receipt, +) +from tests.fixtures.execution_receipt import ( + DATASET_SHA256, + ECONOMY_POLICYENGINE_BUNDLE, + ECONOMY_RESULT, + HOUSEHOLD_REQUEST, + HOUSEHOLD_RESULT, +) + + +pytest_plugins = ("tests.fixtures.execution_receipt",) + +CONTRACT_FIXTURE = Path(__file__).parents[1] / "fixtures" / "execution_receipt_v1.json" +OPENAPI_SPEC = Path(__file__).parents[2] / "policyengine_api" / "openapi_spec.yaml" + + +def test__given_household_calculation__then_receipt_matches_v1_contract( + mock_execution_receipt_runtime, +): + # Given + expected_receipt = json.loads(CONTRACT_FIXTURE.read_text()) + + # When + receipt = build_household_execution_receipt( + country_id="us", + request_payload=HOUSEHOLD_REQUEST, + result=HOUSEHOLD_RESULT, + ) + + # Then + assert receipt == expected_receipt + + +def test__receipt_hashes_use_shared_rfc8785_vectors(mock_execution_receipt_runtime): + receipt = build_household_execution_receipt( + country_id="us", + request_payload=HOUSEHOLD_REQUEST, + result=HOUSEHOLD_RESULT, + ) + + assert receipt["request_sha256"] == ( + "c70045c3e2fb9e44a191e61c43a40efd35d2fa0ce13f882ed0cb1ac069cc7a43" + ) + assert receipt["result_sha256"] == ( + "590a28fe655915f57d7a092d9bdc3359f2156e350bfe372da4d7790cb88391eb" + ) + + +def test__given_provenance_enrichment__then_result_hash_target_is_unchanged(): + # Given + enriched_result = { + **ECONOMY_RESULT, + "policyengine_bundle": ECONOMY_POLICYENGINE_BUNDLE, + "resolved_app_name": "policyengine-simulation-py4-18-9", + "execution_receipt": {"schema_version": 1}, + } + + # When / Then + assert execution_result_sha256(enriched_result) == execution_result_sha256( + ECONOMY_RESULT + ) + + +def test__given_receipt_for_different_result__then_integrity_check_rejects_it( + mock_execution_receipt_runtime, +): + # Given + receipt = build_household_execution_receipt( + country_id="us", + request_payload=HOUSEHOLD_REQUEST, + result=HOUSEHOLD_RESULT, + ) + + # When / Then + assert receipt is not None + assert not execution_receipt_matches_result(receipt, {"different": "result"}) + + +def test__non_finite_result__is_rejected_before_receipt_is_emitted( + mock_execution_receipt_runtime, +): + with pytest.raises(ValueError, match="finite JSON numbers"): + build_household_execution_receipt( + country_id="us", + request_payload=HOUSEHOLD_REQUEST, + result={"not_json": float("nan")}, + ) + + +def test__given_resolved_economy_bundle__then_receipt_uses_runtime_identity( + mock_execution_receipt_runtime, +): + # When + receipt = build_economy_execution_receipt( + country_id="us", + policyengine_bundle=ECONOMY_POLICYENGINE_BUNDLE, + result=ECONOMY_RESULT, + resolved_app_name="policyengine-simulation-py4-18-9", + run_id="run-123", + ) + + # Then + assert receipt is not None + assert receipt["resolved"]["runtime"] == { + "name": "policyengine", + "version": "4.18.9", + "git_sha": None, + "artifact": { + "name": "policyengine-simulation-py4-18-9", + "version": "4.18.9", + "uri": None, + "revision": None, + "sha256": None, + "build_id": None, + }, + } + assert receipt["resolved"]["model"]["actual"]["version"] == "1.752.2" + assert receipt["resolved"]["model"]["certified"]["version"] == "1.752.2" + assert receipt["resolved"]["data"]["actual"] == { + "name": "populace-data", + "version": "0.1.0", + "sha256": None, + "wheel_url": None, + } + assert receipt["resolved"]["population_artifact"]["sha256"] == (DATASET_SHA256) + assert receipt["run_id"] == "run-123" + assert len(receipt["result_sha256"]) == 64 + + +def test__given_uncertified_dataset__then_receipt_does_not_copy_certified_hash( + mock_execution_receipt_runtime, +): + # Given + runtime_bundle = { + **ECONOMY_POLICYENGINE_BUNDLE, + "data_version": "other-release", + "dataset": "hf://example/other.h5@other-release", + } + + # When + receipt = build_economy_execution_receipt( + country_id="us", + policyengine_bundle=runtime_bundle, + result=ECONOMY_RESULT, + ) + + # Then + assert receipt is not None + assert receipt["resolved"]["data"] is None + assert receipt["resolved"]["population_artifact"]["sha256"] is None + assert receipt["resolved"]["population_artifact"]["build_id"] == ("other-release") + + +def test__given_missing_runtime_version__then_no_economy_receipt_is_fabricated( + mock_execution_receipt_runtime, +): + # When + receipt = build_economy_execution_receipt( + country_id="us", + policyengine_bundle={ + "model_version": "1.752.2", + "dataset": "default", + }, + result=ECONOMY_RESULT, + ) + + # Then + assert receipt is None + + +@pytest.mark.parametrize( + "runtime_bundle", + [ + { + "model_version": "1.752.2", + "policyengine_version": "4.18.9", + "data_version": None, + "dataset": "default", + }, + { + "model_version": "1.752.2", + "policyengine_version": "4.18.9", + "data_version": None, + "dataset": ECONOMY_POLICYENGINE_BUNDLE["dataset"], + }, + { + "model_version": "1.752.2", + "policyengine_version": "4.18.9", + "data_version": "release-1", + "dataset": "populace_us_2024", + }, + ], +) +def test__given_unresolved_economy_dataset__then_no_receipt_is_fabricated( + mock_execution_receipt_runtime, + runtime_bundle, +): + # When + receipt = build_economy_execution_receipt( + country_id="us", + policyengine_bundle=runtime_bundle, + result=ECONOMY_RESULT, + ) + + # Then + assert receipt is None + + +def test__given_valid_axiom_receipt__then_engine_neutral_validation_accepts_it(): + # Given + receipt = { + "schema_version": 1, + "requested": { + "engine": "axiom", + "bundle": None, + "model": None, + "data": None, + "ruleset": "us-federal@2026", + "population": None, + "numeric_mode": "decimal", + }, + "resolved": { + "runtime": { + "name": "axiom", + "version": "0.4.0", + "git_sha": "abc123", + "artifact": None, + }, + "numeric_mode": "decimal", + "model": None, + "data": None, + "ruleset_artifact": { + "name": "us-federal", + "version": "2026.1", + "uri": None, + "revision": None, + "sha256": "c" * 64, + "build_id": None, + }, + "population_artifact": None, + "certified_release": None, + "bundle_trace": None, + }, + "run_id": "axiom-run-1", + "created_at": "2026-07-09T12:00:00Z", + "request_sha256": "d" * 64, + "result_sha256": "e" * 64, + } + + # Then + assert is_valid_execution_receipt(receipt) + + +def test__given_shallow_or_malformed_receipt__then_validation_rejects_it(): + assert not is_valid_execution_receipt( + { + "schema_version": True, + "requested": {}, + "resolved": { + "runtime": {"name": "axiom", "version": "0.4.0"}, + "numeric_mode": "decimal", + }, + } + ) + assert not is_valid_execution_receipt( + { + "schema_version": 1, + "requested": {}, + "resolved": { + "runtime": {"name": "axiom"}, + "numeric_mode": "decimal", + }, + } + ) + assert not is_valid_execution_receipt( + { + "schema_version": 1, + "requested": {}, + "resolved": { + "runtime": { + "name": "axiom", + "version": "0.4.0", + "artifact": None, + }, + "numeric_mode": "decimal", + "ruleset_artifact": { + "name": "rules", + "sha256": "not-a-sha256", + }, + }, + } + ) + assert not is_valid_execution_receipt( + { + "schema_version": 1, + "requested": {}, + "resolved": { + "runtime": { + "name": "axiom", + "version": "0.4.0", + "artifact": None, + }, + "numeric_mode": "decimal", + "model": { + "actual": None, + "certified": { + "name": "policyengine-us", + "version": "1.0.0", + }, + }, + }, + } + ) + + +def test__given_forged_certified_package_sha__then_validation_rejects_it(): + # Given + receipt = json.loads(CONTRACT_FIXTURE.read_text()) + receipt["resolved"]["model"]["certified"]["sha256"] = "f" * 64 + + # When / Then + assert not is_valid_execution_receipt(receipt) + + +def test__given_execution_receipt_openapi__then_v1_field_shape_cannot_drift(): + # Given + fixture = json.loads(CONTRACT_FIXTURE.read_text()) + spec = yaml.safe_load(OPENAPI_SPEC.read_text()) + schemas = spec["components"]["schemas"] + + # Then + assert set(schemas["ExecutionReceipt"]["properties"]) == set(fixture) + assert set(schemas["RequestedExecutionAliases"]["properties"]) == set( + fixture["requested"] + ) + assert set(schemas["ResolvedExecutionBundle"]["properties"]) == set( + fixture["resolved"] + ) + assert set(schemas["RuntimeIdentity"]["properties"]) == set( + fixture["resolved"]["runtime"] + ) + assert set(schemas["PackageResolution"]["properties"]) == set( + fixture["resolved"]["model"] + ) + assert schemas["PackageResolution"]["required"] == ["actual"] + assert set(schemas["ArtifactIdentity"]["properties"]) == { + "name", + "version", + "uri", + "revision", + "sha256", + "build_id", + } + certified_release = fixture["resolved"]["certified_release"] + assert set(schemas["CountryReleaseManifest"]["properties"]) == set( + certified_release + ) + assert set(schemas["DataPackageVersion"]["properties"]) == set( + certified_release["data_package"] + ) + assert set(schemas["ArtifactPathReference"]["properties"]) == set( + certified_release["datasets"]["populace_us_2024"] + ) + assert set(schemas["ArtifactPathTemplate"]["properties"]) == set( + certified_release["region_datasets"]["national"] + ) + assert set(schemas["CertifiedDataArtifact"]["properties"]) == set( + certified_release["certified_data_artifact"] + ) + assert set(schemas["DataCertification"]["properties"]) == set( + certified_release["certification"] + ) + assert schemas["ResolvedExecutionBundle"]["properties"]["certified_release"][ + "allOf" + ][0]["$ref"].endswith("/CountryReleaseManifest") + assert schemas["CountryReleaseManifest"]["properties"]["datasets"][ + "additionalProperties" + ]["$ref"].endswith("/ArtifactPathReference") + assert schemas["CountryReleaseManifest"]["properties"]["region_datasets"][ + "additionalProperties" + ]["$ref"].endswith("/ArtifactPathTemplate") + + calculate_properties = spec["paths"]["/{country_id}/calculate"]["post"][ + "responses" + ][200]["content"]["application/json"]["schema"]["properties"] + economy_result_properties = spec["paths"][ + "/{country_id}/economy/{policy_id}/over/{baseline_policy_id}" + ]["get"]["responses"][200]["content"]["application/json"]["schema"]["properties"][ + "result" + ]["properties"] + assert calculate_properties["execution_receipt"]["$ref"].endswith( + "/ExecutionReceipt" + ) + persisted_household_properties = spec["paths"][ + "/{country_id}/household/{household_id}/policy/{policy_id}" + ]["get"]["responses"][200]["content"]["application/json"]["schema"]["properties"] + assert persisted_household_properties["execution_receipt"]["$ref"].endswith( + "/ExecutionReceipt" + ) + assert economy_result_properties["execution_receipt"]["$ref"].endswith( + "/ExecutionReceipt" + ) diff --git a/tests/unit/test_migration_contract_artifacts.py b/tests/unit/test_migration_contract_artifacts.py index f8c3142d3..b008224b0 100644 --- a/tests/unit/test_migration_contract_artifacts.py +++ b/tests/unit/test_migration_contract_artifacts.py @@ -11,7 +11,7 @@ def test_migration_contract_payload_summarizes_route_contracts(): assert payload["metadata"] == { "route_group_count": 10, "workflow_count": 7, - "request_count": 14, + "request_count": 15, "db_entity_count": 6, "sim_flow_count": 3, } diff --git a/uv.lock b/uv.lock index 1b7cbb698..fdac3edc3 100644 --- a/uv.lock +++ b/uv.lock @@ -2616,7 +2616,7 @@ models = [ [[package]] name = "policyengine-api" -version = "3.43.7" +version = "3.45.0" source = { editable = "." } dependencies = [ { name = "a2wsgi" }, @@ -2644,6 +2644,7 @@ dependencies = [ { name = "pymysql" }, { name = "python-dotenv" }, { name = "redis" }, + { name = "rfc8785" }, { name = "rq" }, { name = "sqlalchemy" }, { name = "streamlit" }, @@ -2694,6 +2695,7 @@ requires-dist = [ { name = "pytest-timeout", marker = "extra == 'dev'" }, { name = "python-dotenv" }, { name = "redis" }, + { name = "rfc8785", specifier = "==0.1.4" }, { name = "rq" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, { name = "sqlalchemy", specifier = ">=2,<3" }, @@ -3457,6 +3459,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] +[[package]] +name = "rfc8785" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/2f/fa1d2e740c490191b572d33dbca5daa180cb423c24396b856f5886371d8b/rfc8785-0.1.4.tar.gz", hash = "sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da", size = 14321, upload-time = "2024-09-27T16:33:31.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/78/119878110660b2ad709888c8a1614fce7e2fab39080ab960656dc8605bf6/rfc8785-0.1.4-py3-none-any.whl", hash = "sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48", size = 9240, upload-time = "2024-09-27T16:33:29.683Z" }, +] + [[package]] name = "rich" version = "15.0.0" From 8541ea640be791ef95754905a21e876f3645ad02 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 9 Jul 2026 21:31:21 -0400 Subject: [PATCH 2/2] Add execution receipt changelog fragment --- changelog.d/execution-receipt-api.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/execution-receipt-api.added.md diff --git a/changelog.d/execution-receipt-api.added.md b/changelog.d/execution-receipt-api.added.md new file mode 100644 index 000000000..1fc949dfc --- /dev/null +++ b/changelog.d/execution-receipt-api.added.md @@ -0,0 +1 @@ +Add engine-neutral execution receipts to household and economy calculation responses.