Skip to content

Copy reused and older v1 policies to v2, reuse existing copies, and log why a copy fails - #4036

Draft
anth-volk wants to merge 8 commits into
masterfrom
v2plan/1k-142-copy-reused-policies
Draft

anth-volk wants to merge 8 commits into
masterfrom
v2plan/1k-142-copy-reused-policies

Conversation

@anth-volk

Copy link
Copy Markdown
Collaborator

Fixes #4035

Summary

  • Older and reused policies copy. translate_legacy_policy no longer compares the v1 row's stored api_version with 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 each POST /{country}/policy that returns an existing row (HTTP 200) and every saved policy whose reform was saved under an older release.
  • An existing copy is reused. When legacy_policy_mappings already maps the v1 policy and the source hash matches, mirror_legacy_policy_in_session returns that copy without reading the catalog or translating again (policy_created and mapping_created false). 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.
  • Every failed copy logs a fixed reason. v1_policy_mirror_operations and v1_user_policy_mirror_operations records gain failure_reason next to failure_category, for example missing_parameters, invalid_period, catalog_absent or database_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 log failure_reason: null. The Stage 10 runbook lists every value.
  • One shared parameter check. parameters_missing_from_model lists, 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.
  • A parameter whose period object is empty ({}) is still refused, now as empty_parameter_values instead of a misleading catalog error. A policy with more than 1,000 dated values is refused as too_many_values.

Migration notes

  • Route groups covered: policy (dual-write of POST /{country}/policy) and the saved-policy copy behind /{country}/user-policy, which uses the same translation.
  • What stays on the Flask/v1 path: everything. v1 responses, reads and status codes are unchanged; only the copy into the v2 tables and its log records change.
  • User-visible API contract changes: none. The generated migration contracts are unchanged.
  • One integration test changes expectation: a historical mapping whose v2 values differ from the stored v1 values but share its source hash is now reused unchanged instead of refused, because an existing copy is no longer translated again.

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_write is 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 with outcome=ok, including writes that returned HTTP 200, and, for any remaining errors, their new failure_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 with legacy policy api_version does not match the running country package, including the two production shapes (US policy 96567 setting only gov.dol.minimum_wage, saved under us 1.633.2; UK policy 97839 setting only gov.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 with the running PolicyEngine.py 6.7.5 catalog is absent for us and one with every legacy parameter path must exist in the running catalog. Then 6 passed.
  • 5122f2794 (translation reasons, shared check): the new tests failed at import (LegacyPolicyTranslationReason did not exist); then 23 passed.
  • 58bd52ec1 (log failure_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 was conflicting_values); then it passed.
  • e3ce42f2e (review round 1: concurrent copy on another catalog version): 1 test failed with legacy 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 (PolicyCopyFailureCategory did not exist); then it passed.
  • On every commit: ruff format --check ., ruff check ., uv run --frozen --extra dev mypy, python scripts/run_quality_guards.py, and the Phase 10 policy test group from docs/engineering/skills/testing.md (448 to 483 passed as tests were added). python scripts/export_migration_contracts.py leaves the generated files unchanged.
  • After rebasing onto the current master: the whole tests/unit and tests/contract suites (3,378 passed).
  • Not run locally: the MySQL and PostgreSQL integration suites (tests/integration/test_v2_policy_persistence.py, test_mysql_policy_dual_write.py and 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 # noqa suppressions are added.

🤖 Generated with Claude Code

anth-volk and others added 8 commits October 26, 2026 16:54
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

1 inactive deployment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dual-write does not copy reused or older policies to v2

1 participant