Repository navigation
Conversation
translate_legacy_policy refused any v1 policy whose stored api_version
differed from the country package version the Public API runs. When a
POST /{country}/policy matches an existing row, the route returns that
row with the version it was saved under, so every such dual-write copy
failed as catalog_or_translation, and so did every older saved policy.
The Public API already simulates an old policy with the running country
package, so the copy now binds it to the running catalog the same way:
a policy copies when every parameter path it sets exists in the running
catalog and its periods and values are valid. The version comparison and
the country_package_versions argument are gone.
The new tests copy the shapes of the two production failures (US policy
96567 saved under 1.633.2, UK policy 97839 saved under 2.89.2) and
replace the test that pinned the refusal. The Stage 10 runbook says
which policies copy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mirror_legacy_policy_in_session translated the v1 policy again even when legacy_policy_mappings already held its copy, then checked the result against the mapping. After a country package upgrade the translation could fail as catalog_or_translation, and after a PolicyEngine.py upgrade it bound the content to a new model version, so the content hash changed and the check failed as integrity. Writing the same policy again therefore failed after every upgrade. v1 policies never change, so a mapping whose source hash matches is the copy whatever catalog is running. The copy now returns it at once, with policy_created and mapping_created false. A different source hash is still an integrity error. The MySQL integration test for a historical first-write mapping now expects that mapping to be reused unchanged rather than refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…meter check
LegacyPolicyTranslationError now carries a reason from a fixed set:
missing_parameters, invalid_parameter_values, empty_parameter_values,
invalid_period or conflicting_values. The message is still never logged.
parameters_missing_from_model lists, once each and sorted, the parameter
paths a policy sets that a model does not have. The translation uses it
for its missing-parameter check, so later code that reports a policy's
missing parameters can apply exactly the same rule.
A parameter whose period object is empty ({}) sets no value. It used to
pass translation and fail later in validate_policy_catalog with a
PolicyCatalogValidationError that named the wrong cause. It is still
refused, now as empty_parameter_values.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every failed copy record logged only a broad failure_category, so all three production failures since dual-write was turned on read catalog_or_translation with no way to tell their causes apart from the logs. Each v1_policy_mirror_operations and v1_user_policy_mirror_operations error record now also carries failure_reason, a code taken only from the exception type: the translation's own reason (missing_parameters, invalid_period and so on), catalog_absent or catalog_query_failed, mapping_conflict, database_error, unexpected, and the rest listed in the Stage 10 runbook. A saved policy whose reform policy cannot be loaded logs missing_reform_policy. The records written by the route when no copy was attempted repeat their category as the reason. No policy content or exception text is logged; success records log failure_reason null. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… size limit translate_legacy_policy mapped every ValueError from PolicyCreationInput to conflicting_values. PolicyCreationInput also refuses more than 1,000 dated values, and the v1 route puts no limit on a policy's size, so a large v1 policy logged a conflict that was not there. The count is now checked first and logged as too_many_values; what is left for conflicting_values is two values for one parameter with the same start date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…first is When two first copies of the same v1 policy run at once and this one loses the mapping insert, the copy still required the winner's v2 policy to equal its own. Two copies on revisions with different PolicyEngine.py versions translate to different v2 policies, so the loser logged mapping_conflict for a mapping that is valid under the rule the previous commit introduced: the same source hash makes the mapped v2 policy the copy. The race branch now applies that rule too, and verify_legacy_policy_mapping no longer takes expected_policy_id. The losing copy's own v2 policy row is then committed without a mapping. It is immutable, deduplicated content that nothing refers to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous commits classified a failed copy twice: once for failure_category and once for failure_reason, and the saved-policy copy had a third copy of the category rules. classify_policy_copy_failure now returns both from one table of exception types, and the saved-policy copy adds only its two own cases. PolicyCopyFailureReason also holds the route's missing_mirror_snapshot and missing_mirror_revision, so the logged values are typed and listed in one place. Every LegacyUserPolicyIntegrityError is now saved_policy_integrity, because that exception covers more checks than mapping conflicts. LegacyPolicyTranslationReason moves to the policies types module, where the v2 code organization guide puts shared enums. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
failure_reason came from enums but failure_category was a plain string repeated across the copy modules and the route. PolicyCopyFailureCategory now holds every category, and the route's two no-copy records and the unexpected case are PolicyCopyFailure constants, so both logged fields come from fixed sets. The comment on the mapping-race branch and the Stage 10 runbook now say what happens when two first copies of one v1 policy run on revisions with different PolicyEngine.py versions: the mapping committed first is the copy, and the other request's v2 policy commits without a mapping, its record reporting policy_created=false. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch was previously deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4035
Summary
translate_legacy_policyno longer compares the v1 row's storedapi_versionwith the running country package version. A v1 policy is copied whenever every parameter path it sets exists in the running catalog and its periods and values are valid, which is how the Public API already simulates an old policy. This covers eachPOST /{country}/policythat returns an existing row (HTTP 200) and every saved policy whose reform was saved under an older release.legacy_policy_mappingsalready maps the v1 policy and the source hash matches,mirror_legacy_policy_in_sessionreturns that copy without reading the catalog or translating again (policy_createdandmapping_createdfalse). Writing the same policy again after a PolicyEngine.py or country package upgrade therefore succeeds. The branch for a mapping inserted concurrently applies the same rule.v1_policy_mirror_operationsandv1_user_policy_mirror_operationsrecords gainfailure_reasonnext tofailure_category, for examplemissing_parameters,invalid_period,catalog_absentordatabase_error. Both fields come only from the exception type (and the translation's own reason), from two fixed enums; no policy content or exception text is logged. Successful copies logfailure_reason: null. The Stage 10 runbook lists every value.parameters_missing_from_modellists, once each and sorted, the parameter paths a policy sets that a model lacks. The translation uses it; the follow-up PR that reports a policy's missing parameters uses the same function.{}) is still refused, now asempty_parameter_valuesinstead of a misleading catalog error. A policy with more than 1,000 dated values is refused astoo_many_values.Migration notes
policy(dual-write ofPOST /{country}/policy) and the saved-policy copy behind/{country}/user-policy, which uses the same translation.Environment variables
None. The change reads no new environment variables and touches no workflow, image or infrastructure. It acts only where
DB_WRITE_POLICY=dual_writeis already set.What this PR waits for
Review only; it has no schema change and no deployment dependency.
After it deploys, an operator should confirm successful copies in the production logs (
jsonPayload.attributes.metric_name="v1_policy_mirror_operations"): records withoutcome=ok, including writes that returned HTTP 200, and, for any remaining errors, their newfailure_reason. This changes what production's dual-write does.Testing
Each commit was written test-first; the failing run came before the code change.
0acc0be99(copy older policies): 6 tests failed first withlegacy policy api_version does not match the running country package, including the two production shapes (US policy 96567 setting onlygov.dol.minimum_wage, saved under us 1.633.2; UK policy 97839 setting onlygov.boe.base_rate, saved under uk 2.89.2). Then 22 passed in the two files.4f09b0995(reuse an existing copy): 2 tests failed first, one withthe running PolicyEngine.py 6.7.5 catalog is absent for usand one withevery legacy parameter path must exist in the running catalog. Then 6 passed.5122f2794(translation reasons, shared check): the new tests failed at import (LegacyPolicyTranslationReasondid not exist); then 23 passed.58bd52ec1(logfailure_reason): 34 tests failed first (KeyError: 'failure_reason'and complete-record mismatches); then 76 passed.3a17a5862(review round 1:too_many_values): the new test failed (no such reason; before, the reason logged wasconflicting_values); then it passed.e3ce42f2e(review round 1: concurrent copy on another catalog version): 1 test failed withlegacy policy mapping does not match translated immutable content; then 8 passed.950bab37b(review round 1: one classification table): 1 test failed ('saved_policy_mapping_conflict' == 'saved_policy_integrity'); then 109 passed in the copy test files.db807e809(review round 2: typed category): the new test failed at import (PolicyCopyFailureCategorydid not exist); then it passed.ruff format --check .,ruff check .,uv run --frozen --extra dev mypy,python scripts/run_quality_guards.py, and the Phase 10 policy test group fromdocs/engineering/skills/testing.md(448 to 483 passed as tests were added).python scripts/export_migration_contracts.pyleaves the generated files unchanged.tests/unitandtests/contractsuites (3,378 passed).tests/integration/test_v2_policy_persistence.py,test_mysql_policy_dual_write.pyand the other dual-write suites), because no disposable databases were available. Both changed files collect cleanly. GitHub Actions runs on this branch are cancelled at the user's request, so CI has not run them either.No inline
# noqasuppressions are added.🤖 Generated with Claude Code