From 762f2c6fcd5a11b9a7e1429084695831fe990db1 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:32:32 +0400 Subject: [PATCH 1/3] Add optional withheld-area code lists to the local-area breakdown result LocalAreaBreakdownOutput gains constituency_withheld_codes and local_authority_withheld_codes, each list[str] | None defaulting to None: the sorted ONS codes of that level's areas that the run estimated but disclosure control did not publish. An empty list means none were withheld; None means the result does not report them, so results from older executors still validate. The gateway's golden OpenAPI, the entrypoint's OpenAPI test and the gateway README describe the fields. Co-Authored-By: Claude Opus 5.5 --- .../macro_output.py | 10 ++++++ .../tests/test_local_area_breakdown.py | 36 +++++++++++++++++++ .../tests/test_openapi.py | 14 ++++++++ .../policyengine-simulation-gateway/README.md | 1 + .../tests/golden/openapi.json | 30 +++++++++++++++- 5 files changed, 90 insertions(+), 1 deletion(-) diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py index 674992ace..b4185f12e 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py @@ -364,6 +364,14 @@ class LocalAreaBreakdownOutput(MacroOutputModel): policyengine.py release that computed it. Each list holds only its own level's records. Areas whose income change disclosure control withholds are left out of each list, so a list may be empty. + + ``constituency_withheld_codes`` and ``local_authority_withheld_codes`` + hold, sorted, the ONS codes of that level's areas that the run estimated + but disclosure control did not publish, so a client can tell a withheld + area from an absent one. An area the run never estimated, such as one with + no households in the dataset, is in neither list. An empty list means + that no area of the level was withheld. Null means that the result does + not report them, as results from older executors do not. """ kind: Literal["local_areas"] @@ -373,3 +381,5 @@ class LocalAreaBreakdownOutput(MacroOutputModel): data_version: str constituency_impact: list[ConstituencyImpactRecord] local_authority_impact: list[LocalAuthorityImpactRecord] + constituency_withheld_codes: list[str] | None = None + local_authority_withheld_codes: list[str] | None = None diff --git a/libs/policyengine-simulation-contract/tests/test_local_area_breakdown.py b/libs/policyengine-simulation-contract/tests/test_local_area_breakdown.py index cdd80eaf1..5ccc7a5f5 100644 --- a/libs/policyengine-simulation-contract/tests/test_local_area_breakdown.py +++ b/libs/policyengine-simulation-contract/tests/test_local_area_breakdown.py @@ -56,6 +56,14 @@ "data_version": "microcosm-uk-2024-25-dense-20261019T000432Z-ee5c2dbe", "constituency_impact": [CONSTITUENCY], "local_authority_impact": [LOCAL_AUTHORITY], + "constituency_withheld_codes": ["E14000530", "E14001172"], + "local_authority_withheld_codes": [], +} +# A result from an executor that does not report the withheld areas. +BREAKDOWN_WITHOUT_WITHHELD_CODES = { + key: value + for key, value in BREAKDOWN.items() + if not key.endswith("_withheld_codes") } _ZERO = {"baseline": 0.0, "reform": 0.0} _AGE_POVERTY = {"child": _ZERO, "adult": _ZERO, "senior": _ZERO, "all": _ZERO} @@ -115,6 +123,34 @@ def test_a_breakdown_result_carries_both_area_levels_and_their_provenance(): assert output.model_dump(mode="json") == BREAKDOWN +def test_a_breakdown_result_lists_the_withheld_areas_of_each_level(): + output = LocalAreaBreakdownOutput.model_validate(BREAKDOWN) + + assert output.constituency_withheld_codes == ["E14000530", "E14001172"] + # An empty list reports that disclosure control withheld no area. + assert output.local_authority_withheld_codes == [] + + +def test_a_breakdown_result_without_withheld_codes_still_validates(): + output = LocalAreaBreakdownOutput.model_validate(BREAKDOWN_WITHOUT_WITHHELD_CODES) + + # None means the result does not report which areas were withheld. + assert output.constituency_withheld_codes is None + assert output.local_authority_withheld_codes is None + response = JobStatusResponse( + status="complete", result=BREAKDOWN_WITHOUT_WITHHELD_CODES + ) + assert isinstance(response.result, LocalAreaBreakdownOutput) + + +@pytest.mark.parametrize( + "field", ["constituency_withheld_codes", "local_authority_withheld_codes"] +) +def test_withheld_codes_are_a_list_of_area_codes(field): + with pytest.raises(ValidationError): + LocalAreaBreakdownOutput.model_validate({**BREAKDOWN, field: "E14000530"}) + + def test_a_job_status_reads_a_breakdown_result_as_a_breakdown(): response = JobStatusResponse(status="complete", result=BREAKDOWN) diff --git a/projects/policyengine-simulation-entry/tests/test_openapi.py b/projects/policyengine-simulation-entry/tests/test_openapi.py index 060b6bb8c..b31f97ada 100644 --- a/projects/policyengine-simulation-entry/tests/test_openapi.py +++ b/projects/policyengine-simulation-entry/tests/test_openapi.py @@ -122,6 +122,20 @@ def test_local_area_breakdowns_are_public(): ]["properties"]["result"]["anyOf"] +def test_local_area_breakdowns_may_list_the_withheld_areas_of_each_level(): + breakdown = create_app().openapi()["components"]["schemas"][ + "LocalAreaBreakdownOutput" + ] + + for field in ("constituency_withheld_codes", "local_authority_withheld_codes"): + assert breakdown["properties"][field]["anyOf"] == [ + {"type": "array", "items": {"type": "string"}}, + {"type": "null"}, + ] + # Optional, so results from executors that do not report them validate. + assert field not in breakdown["required"] + + def test_route_table_is_frozen(): assert methods(cast("OpenAPIDocument", create_app().openapi())) == EXPECTED_ROUTES diff --git a/projects/policyengine-simulation-gateway/README.md b/projects/policyengine-simulation-gateway/README.md index 033836a95..9f837c8bb 100644 --- a/projects/policyengine-simulation-gateway/README.md +++ b/projects/policyengine-simulation-gateway/README.md @@ -104,6 +104,7 @@ instead of a `SingleYearMacroOutput`: | `model_version`, `policyengine_version` | The country model and the policyengine.py release that computed it. | | `dataset`, `data_version` | The local-area dataset's name and release revision. | | `constituency_impact`, `local_authority_impact` | The published areas of each level. Disclosure control leaves withheld areas out, so a list may be empty; each record carries a `disclosure_tier` (`reported` or `approximate`) and 90% interval bounds. | +| `constituency_withheld_codes`, `local_authority_withheld_codes` | The ONS codes, sorted, of each level's areas that the run estimated but disclosure control did not publish. An area the run never estimated, such as one with no households in the dataset, is in neither list, so an area absent from both the records and this list has no estimate. An empty list means that no area was withheld. Results from older executors don't report them: the fields are null, and the status body omits them. | Rules: diff --git a/projects/policyengine-simulation-gateway/tests/golden/openapi.json b/projects/policyengine-simulation-gateway/tests/golden/openapi.json index 715cd2c5a..ff61f5041 100644 --- a/projects/policyengine-simulation-gateway/tests/golden/openapi.json +++ b/projects/policyengine-simulation-gateway/tests/golden/openapi.json @@ -1779,6 +1779,34 @@ }, "type": "array", "title": "Local Authority Impact" + }, + "constituency_withheld_codes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Constituency Withheld Codes" + }, + "local_authority_withheld_codes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Local Authority Withheld Codes" } }, "additionalProperties": false, @@ -1793,7 +1821,7 @@ "local_authority_impact" ], "title": "LocalAreaBreakdownOutput", - "description": "Completed result of a UK local-area breakdown run.\n\nA request with ``breakdown: \"local_areas\"`` returns this instead of a\n``SingleYearMacroOutput``. The run covers the requested UK-wide or nation\nregion on the local-area dataset alone, and computes only the\nconstituency and local-authority impacts; every other figure comes from\nthe national run. ``kind`` is always ``local_areas``. ``dataset`` names the\nlocal-area dataset, ``data_version`` its release revision, and\n``model_version`` and ``policyengine_version`` the country model and the\npolicyengine.py release that computed it. Each list holds only its own\nlevel's records. Areas whose income change disclosure control withholds\nare left out of each list, so a list may be empty." + "description": "Completed result of a UK local-area breakdown run.\n\nA request with ``breakdown: \"local_areas\"`` returns this instead of a\n``SingleYearMacroOutput``. The run covers the requested UK-wide or nation\nregion on the local-area dataset alone, and computes only the\nconstituency and local-authority impacts; every other figure comes from\nthe national run. ``kind`` is always ``local_areas``. ``dataset`` names the\nlocal-area dataset, ``data_version`` its release revision, and\n``model_version`` and ``policyengine_version`` the country model and the\npolicyengine.py release that computed it. Each list holds only its own\nlevel's records. Areas whose income change disclosure control withholds\nare left out of each list, so a list may be empty.\n\n``constituency_withheld_codes`` and ``local_authority_withheld_codes``\nhold, sorted, the ONS codes of that level's areas that the run estimated\nbut disclosure control did not publish, so a client can tell a withheld\narea from an absent one. An area the run never estimated, such as one with\nno households in the dataset, is in neither list. An empty list means\nthat no area of the level was withheld. Null means that the result does\nnot report them, as results from older executors do not." }, "LocalAuthorityImpactRecord": { "properties": { From c9b3a89a83c6e2a41fe1d07409c8097c2a502536 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:26:02 +0400 Subject: [PATCH 2/3] Leave unreported withheld-code lists out of breakdown result JSON With the fields defaulting to None, an executor that does not fill them dumped both keys as null. A gateway deployed before the fields existed forbids unknown keys, so it would report such a breakdown as failed if the executor were redeployed first. Both fields now use field-level exclude_if, so a None list is left out of every dump while [] is kept. The contract requires pydantic 2.12, the first release with exclude_if; every lock already resolves 2.13, and the five locks that include the contract record only the new requirement. Co-Authored-By: Claude Opus 5.5 --- .../pyproject.toml | 2 +- .../macro_output.py | 17 +++++++++--- .../tests/test_local_area_breakdown.py | 26 +++++++++++++++++++ libs/policyengine-simulation-contract/uv.lock | 2 +- libs/policyengine-stage12-persistence/uv.lock | 2 +- .../policyengine-simulation-entry/uv.lock | 2 +- .../policyengine-simulation-executor/uv.lock | 2 +- .../policyengine-simulation-gateway/README.md | 2 +- .../tests/golden/openapi.json | 2 +- .../policyengine-simulation-gateway/uv.lock | 2 +- 10 files changed, 47 insertions(+), 12 deletions(-) diff --git a/libs/policyengine-simulation-contract/pyproject.toml b/libs/policyengine-simulation-contract/pyproject.toml index 7214eee3b..1de678f91 100644 --- a/libs/policyengine-simulation-contract/pyproject.toml +++ b/libs/policyengine-simulation-contract/pyproject.toml @@ -8,7 +8,7 @@ authors = [ license = {file = "../../LICENSE"} requires-python = ">=3.13" dependencies = [ - "pydantic>=2.0", + "pydantic>=2.12", "packaging>=24", # gateway_models embeds the TelemetryEnvelope from the observability lib. "policyengine-simulation-observability", diff --git a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py index b4185f12e..415c53013 100644 --- a/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py +++ b/libs/policyengine-simulation-contract/src/policyengine_simulation_contract/macro_output.py @@ -4,7 +4,7 @@ from typing import Generic, Literal, TypeVar -from pydantic import BaseModel, ConfigDict, RootModel +from pydantic import BaseModel, ConfigDict, Field, RootModel from policyengine_simulation_contract.spm import SPMComparisonProvenance @@ -351,6 +351,10 @@ class SingleYearMacroOutput(MacroOutputModel): cliff_impact: CliffImpactOutput | None = None +def _is_none(value: object) -> bool: + return value is None + + class LocalAreaBreakdownOutput(MacroOutputModel): """Completed result of a UK local-area breakdown run. @@ -371,7 +375,8 @@ class LocalAreaBreakdownOutput(MacroOutputModel): area from an absent one. An area the run never estimated, such as one with no households in the dataset, is in neither list. An empty list means that no area of the level was withheld. Null means that the result does - not report them, as results from older executors do not. + not report them, as results from older executors do not; the JSON then + leaves both keys out rather than writing null. """ kind: Literal["local_areas"] @@ -381,5 +386,9 @@ class LocalAreaBreakdownOutput(MacroOutputModel): data_version: str constituency_impact: list[ConstituencyImpactRecord] local_authority_impact: list[LocalAuthorityImpactRecord] - constituency_withheld_codes: list[str] | None = None - local_authority_withheld_codes: list[str] | None = None + constituency_withheld_codes: list[str] | None = Field( + default=None, exclude_if=_is_none + ) + local_authority_withheld_codes: list[str] | None = Field( + default=None, exclude_if=_is_none + ) diff --git a/libs/policyengine-simulation-contract/tests/test_local_area_breakdown.py b/libs/policyengine-simulation-contract/tests/test_local_area_breakdown.py index 5ccc7a5f5..98ecc3ae4 100644 --- a/libs/policyengine-simulation-contract/tests/test_local_area_breakdown.py +++ b/libs/policyengine-simulation-contract/tests/test_local_area_breakdown.py @@ -143,6 +143,32 @@ def test_a_breakdown_result_without_withheld_codes_still_validates(): assert isinstance(response.result, LocalAreaBreakdownOutput) +def test_a_breakdown_result_without_withheld_codes_dumps_without_them(): + # A gateway deployed before the fields existed forbids unknown keys, so a + # result that does not report them must leave the keys out, not send null. + output = LocalAreaBreakdownOutput.model_validate(BREAKDOWN_WITHOUT_WITHHELD_CODES) + + assert output.model_dump(mode="json") == BREAKDOWN_WITHOUT_WITHHELD_CODES + assert "withheld_codes" not in output.model_dump_json() + response = JobStatusResponse( + status="complete", result=BREAKDOWN_WITHOUT_WITHHELD_CODES + ) + assert response.model_dump(mode="json")["result"] == ( + BREAKDOWN_WITHOUT_WITHHELD_CODES + ) + + +def test_a_breakdown_result_keeps_an_empty_withheld_list_in_its_dump(): + # [] reports that no area was withheld, unlike a missing list. + output = LocalAreaBreakdownOutput.model_validate( + {**BREAKDOWN_WITHOUT_WITHHELD_CODES, "constituency_withheld_codes": []} + ) + + dumped = output.model_dump(mode="json") + assert dumped["constituency_withheld_codes"] == [] + assert "local_authority_withheld_codes" not in dumped + + @pytest.mark.parametrize( "field", ["constituency_withheld_codes", "local_authority_withheld_codes"] ) diff --git a/libs/policyengine-simulation-contract/uv.lock b/libs/policyengine-simulation-contract/uv.lock index 8915800ea..3fa7e7d15 100644 --- a/libs/policyengine-simulation-contract/uv.lock +++ b/libs/policyengine-simulation-contract/uv.lock @@ -1122,7 +1122,7 @@ requires-dist = [ { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, { name = "packaging", specifier = ">=24" }, { name = "policyengine-simulation-observability", editable = "../policyengine-simulation-observability" }, - { name = "pydantic", specifier = ">=2.0" }, + { name = "pydantic", specifier = ">=2.12" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, diff --git a/libs/policyengine-stage12-persistence/uv.lock b/libs/policyengine-stage12-persistence/uv.lock index 5e3c5dca8..20d14c19b 100644 --- a/libs/policyengine-stage12-persistence/uv.lock +++ b/libs/policyengine-stage12-persistence/uv.lock @@ -604,7 +604,7 @@ requires-dist = [ { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, { name = "packaging", specifier = ">=24" }, { name = "policyengine-simulation-observability", editable = "../policyengine-simulation-observability" }, - { name = "pydantic", specifier = ">=2.0" }, + { name = "pydantic", specifier = ">=2.12" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, diff --git a/projects/policyengine-simulation-entry/uv.lock b/projects/policyengine-simulation-entry/uv.lock index c3bfa7d53..e9a587c24 100644 --- a/projects/policyengine-simulation-entry/uv.lock +++ b/projects/policyengine-simulation-entry/uv.lock @@ -881,7 +881,7 @@ requires-dist = [ { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, { name = "packaging", specifier = ">=24" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, - { name = "pydantic", specifier = ">=2.0" }, + { name = "pydantic", specifier = ">=2.12" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, diff --git a/projects/policyengine-simulation-executor/uv.lock b/projects/policyengine-simulation-executor/uv.lock index b68fcbe9e..85776f45d 100644 --- a/projects/policyengine-simulation-executor/uv.lock +++ b/projects/policyengine-simulation-executor/uv.lock @@ -1883,7 +1883,7 @@ requires-dist = [ { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, { name = "packaging", specifier = ">=24" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, - { name = "pydantic", specifier = ">=2.0" }, + { name = "pydantic", specifier = ">=2.12" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, diff --git a/projects/policyengine-simulation-gateway/README.md b/projects/policyengine-simulation-gateway/README.md index 9f837c8bb..433d2b651 100644 --- a/projects/policyengine-simulation-gateway/README.md +++ b/projects/policyengine-simulation-gateway/README.md @@ -104,7 +104,7 @@ instead of a `SingleYearMacroOutput`: | `model_version`, `policyengine_version` | The country model and the policyengine.py release that computed it. | | `dataset`, `data_version` | The local-area dataset's name and release revision. | | `constituency_impact`, `local_authority_impact` | The published areas of each level. Disclosure control leaves withheld areas out, so a list may be empty; each record carries a `disclosure_tier` (`reported` or `approximate`) and 90% interval bounds. | -| `constituency_withheld_codes`, `local_authority_withheld_codes` | The ONS codes, sorted, of each level's areas that the run estimated but disclosure control did not publish. An area the run never estimated, such as one with no households in the dataset, is in neither list, so an area absent from both the records and this list has no estimate. An empty list means that no area was withheld. Results from older executors don't report them: the fields are null, and the status body omits them. | +| `constituency_withheld_codes`, `local_authority_withheld_codes` | The ONS codes, sorted, of each level's areas that the run estimated but disclosure control did not publish. An area the run never estimated, such as one with no households in the dataset, is in neither list, so an area absent from both the records and this list has no estimate. An empty list means that no area was withheld. Results from older executors don't report them, and a result that doesn't report them leaves both keys out rather than sending null. | Rules: diff --git a/projects/policyengine-simulation-gateway/tests/golden/openapi.json b/projects/policyengine-simulation-gateway/tests/golden/openapi.json index ff61f5041..2b5d0fbea 100644 --- a/projects/policyengine-simulation-gateway/tests/golden/openapi.json +++ b/projects/policyengine-simulation-gateway/tests/golden/openapi.json @@ -1821,7 +1821,7 @@ "local_authority_impact" ], "title": "LocalAreaBreakdownOutput", - "description": "Completed result of a UK local-area breakdown run.\n\nA request with ``breakdown: \"local_areas\"`` returns this instead of a\n``SingleYearMacroOutput``. The run covers the requested UK-wide or nation\nregion on the local-area dataset alone, and computes only the\nconstituency and local-authority impacts; every other figure comes from\nthe national run. ``kind`` is always ``local_areas``. ``dataset`` names the\nlocal-area dataset, ``data_version`` its release revision, and\n``model_version`` and ``policyengine_version`` the country model and the\npolicyengine.py release that computed it. Each list holds only its own\nlevel's records. Areas whose income change disclosure control withholds\nare left out of each list, so a list may be empty.\n\n``constituency_withheld_codes`` and ``local_authority_withheld_codes``\nhold, sorted, the ONS codes of that level's areas that the run estimated\nbut disclosure control did not publish, so a client can tell a withheld\narea from an absent one. An area the run never estimated, such as one with\nno households in the dataset, is in neither list. An empty list means\nthat no area of the level was withheld. Null means that the result does\nnot report them, as results from older executors do not." + "description": "Completed result of a UK local-area breakdown run.\n\nA request with ``breakdown: \"local_areas\"`` returns this instead of a\n``SingleYearMacroOutput``. The run covers the requested UK-wide or nation\nregion on the local-area dataset alone, and computes only the\nconstituency and local-authority impacts; every other figure comes from\nthe national run. ``kind`` is always ``local_areas``. ``dataset`` names the\nlocal-area dataset, ``data_version`` its release revision, and\n``model_version`` and ``policyengine_version`` the country model and the\npolicyengine.py release that computed it. Each list holds only its own\nlevel's records. Areas whose income change disclosure control withholds\nare left out of each list, so a list may be empty.\n\n``constituency_withheld_codes`` and ``local_authority_withheld_codes``\nhold, sorted, the ONS codes of that level's areas that the run estimated\nbut disclosure control did not publish, so a client can tell a withheld\narea from an absent one. An area the run never estimated, such as one with\nno households in the dataset, is in neither list. An empty list means\nthat no area of the level was withheld. Null means that the result does\nnot report them, as results from older executors do not; the JSON then\nleaves both keys out rather than writing null." }, "LocalAuthorityImpactRecord": { "properties": { diff --git a/projects/policyengine-simulation-gateway/uv.lock b/projects/policyengine-simulation-gateway/uv.lock index 082898381..8e709799d 100644 --- a/projects/policyengine-simulation-gateway/uv.lock +++ b/projects/policyengine-simulation-gateway/uv.lock @@ -1304,7 +1304,7 @@ requires-dist = [ { name = "modal", marker = "extra == 'test'", specifier = ">=1.4,<2" }, { name = "packaging", specifier = ">=24" }, { name = "policyengine-simulation-observability", editable = "../../libs/policyengine-simulation-observability" }, - { name = "pydantic", specifier = ">=2.0" }, + { name = "pydantic", specifier = ">=2.12" }, { name = "pyright", marker = "extra == 'build'", specifier = ">=1.1.401" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.4" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, From b4d1f32a76cbaa844ffb3789306911cdebef25cd Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:26:41 +0400 Subject: [PATCH 3/3] Describe the withheld-code fields the contract now accepts in the changelog Co-Authored-By: Claude Opus 5.5 --- changelog_entry.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog_entry.yaml b/changelog_entry.yaml index 07925a8fe..0e8be2421 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -1,6 +1,7 @@ - bump: patch changes: added: + - Added optional constituency_withheld_codes and local_authority_withheld_codes to LocalAreaBreakdownOutput, the result of a breakdown=local_areas run, so the contract and the gateway accept a breakdown result with or without them; each will hold, sorted, the ONS codes of that level's areas that the run estimated but disclosure control did not publish ([] when none were withheld, and never an area the run did not estimate); no executor fills them yet; a result without them still validates, and its JSON leaves both keys out rather than writing null, through field-level exclude_if, so the contract now requires pydantic 2.12 or later; the gateway's OpenAPI lists both as optional - Recorded in the routing state when each policyengine.py route was first published (published_at, by version) and when latest.policyengine last changed (latest_since, set by a publish that advances it, a forced latest or a rollback), and returned both from the gateway's /versions; a date is recorded only when the publish is that event, so states written before this change show no date for what they already route instead of a later, wrong one, and both fields are optional, so earlier states still read and validate; only ISO 8601 times with a timezone are kept (normalized to UTC in the state), and /versions types them as timezone-aware datetimes - Added is_tax to each detailed_budget program (optional, null in outputs built before it), copied from policyengine.py's program statistics, so consumers sign a program's change as tax revenue or benefit spending without a list of their own; a UK benefit that has no row in the app's list, such as Scottish Child Payment, was otherwise drawn as a tax - Added the rule sim.dependencies.uv-version-pinned, checked by a new uv-version quality guard with proof fixtures taken from the unpinned setup-uv step and the ghcr.io/astral-sh/uv:latest copy on main; every astral-sh/setup-uv step, uv container image reference, Modal uv_sync or uv_pip_install call, uv install script or release download, pip, pipx or uv tool install of uv, and uv self update must pin the one uv release written in the new .uv-version file, 0.13.6