Skip to content

Rename the v2 table user_policies to user_policy_associations - #4010

Draft
anth-volk wants to merge 4 commits into
v2plan/1f-121-repoint-saved-itemsfrom
v2plan/1f-122-rename-user-policies
Draft

anth-volk wants to merge 4 commits into
v2plan/1f-121-repoint-saved-itemsfrom
v2plan/1f-122-rename-user-policies

Conversation

@anth-volk

Copy link
Copy Markdown
Collaborator

Fixes #4008

Summary

  • The v2 table user_policies is renamed to user_policy_associations, matching user_household_associations, user_simulation_associations and user_report_associations. Its primary key, unique constraint, three check constraints, two foreign keys and two convention-named indexes take the new name; the foreign keys from legacy_user_policy_mappings and user_report_associations follow the table.
  • Deliberate choice: the three composite listing indexes keep their names ix_user_policies_country_user_created_id, ix_user_policies_country_policy and ix_user_policies_country_legacy_policy. The sibling association tables keep short names for the same indexes (ix_user_households_*, ix_user_simulations_*, ix_user_reports_*), so the short names are the existing convention.
  • Saved policies, their v1 mappings, the reports that name them and table grants are kept: the table is renamed in place, not recreated.
  • The model, the table privilege declarations (privileges.py) and V2_ALEMBIC_HEAD_REVISION use the new name. The Stage 10 saved-policy dual-write copy goes through the model and follows it.
  • Migration qualification commands. Both run before every schema upgrade, so they read the saved-policy table under whichever name the database has. The policy qualification counts only while its revision is pending, when the table is still user_policies, so it names that table. The user-association qualification counts on every run, so it counts user_policy_associations when that table exists and user_policies otherwise. Their JSON evidence keeps the key user_policies.
  • Unchanged: the route /v2/user-policies, the user_policy_id columns, legacy_user_policy_mappings, and the v1 Cloud SQL table user_policies.

Migration notes

  • Route groups covered: none change. The policy user-association routes keep their contracts; only the table behind them is renamed. The generated migration contracts do not change.
  • What stays on the Flask/v1 path: everything; v1's Cloud SQL user_policies table is untouched.
  • Newly prepared: one Alembic v2 revision, fa7fa1035444 (parent fbb7488ebe8a).
  • User-visible API contract changes: none.

The migration correction, and why it is within the migration rules

The revision was generated with uv run alembic -c alembic-v2.ini revision --autogenerate. Autogeneration cannot detect a rename: it created an empty user_policy_associations table and dropped user_policies, which would delete every saved policy, and its copies of the three composite indexes would have collided with the old table's indexes of the same names. The repository's Alembic rules allow a minimal, documented, tested post-generation correction of an objectively incorrect autogenerated operation. The revision has one such correction in each direction:

  • op.rename_table replaces the create-and-drop pair.
  • Each constraint the generated table named after user_policy_associations is dropped under its old name and created under the generated name with the generated definition, using Alembic operations only (no op.execute, no data operations).
  • The generated drops and re-creations of the two convention-named indexes and of the three foreign keys that refer to the table are kept, ordered around the rename. The downgrade is the exact reverse.

The correction is documented in the revision and in docs/engineering/skills/alembic-migrations.md ("API v2 saved-policy table rename"). The v2 Postgres lifecycle test stores a saved policy with a v1 mapping and a report that names it, then checks that the rows, references, delete cascades, a table grant (each privilege separately) and every constraint and index name are right after the upgrade and after the downgrade. Rebuilding the primary key, unique and check constraints takes a brief lock on the table during the upgrade.

Left out: the temporary user_policies view

The plan for this rename includes a view named user_policies over the renamed table, so that the release still serving during a deploy keeps working. It is not in this PR. Alembic does not autogenerate views, and creating one would be a hand-written schema operation, which the migration rules leave to a human decision. test_previous_release_still_finds_user_policies records the gap as a strict expected failure: it stores a saved policy, upgrades to head and reads it under the old name. It starts failing, as a reminder to remove the marker, once a view exists.

Without the view, a Public API release from before this revision fails every statement it makes on saved policies, because it names user_policies:

  • During a deploy: from the schema upgrade in the database job until the new release takes traffic (in production, the time deploy-cloud-run-candidate takes; in staging, until the staging deploy).
  • After an application rollback to an earlier release: until a release from this revision or later serves traffic again, or until this revision is downgraded. Downgrading it alone renames the table back and keeps every row.
  • What fails: the previous release's /v2/user-policies routes, user-report reads that join saved policies, the sign-in merge that moves an unauthenticated user's saved policies, and Stage 10 saved-policy dual-write copies (DB_WRITE_POLICY is cloud_sql today, so these are off).

If a view is added later, the runtime role also needs grants on it. That means a privileges.py entry, and a change to the privilege reconciliation, whose table query reads only ordinary and partitioned tables (relkind IN ('r', 'p')), so it would treat a declared view as missing and grant nothing.

The Stage 12A document (docs/migration/stage-12a-user-login.md) now says this in its rollback and schema compatibility sections, and uses the new table name for current behavior.

Environment variables

None. The change reads and adds no environment variables, and touches no workflow, image or infrastructure.

What this PR waits for

  • Let saved reforms and households point at a new v2 policy or household #4005, its base (the bottom of this stack of Public API PRs).
  • A person's decision on the temporary user_policies view described above: merge without it and accept the gap during the deploy, or add the view (with its grants) first.
  • Alembic v2 chain coordination: if another PR with a v2 revision merges first, this revision is rebased onto the new head (one revision merges at a time).

Testing

Each commit was written test-first:

  • 0fda92e3f (qualification commands): test_saved_policies_are_counted_under_their_name_after_the_rename failed first (assert 0 == 3: the renamed table was never counted). Then both qualification unit files passed (16) and the qualification integration test passed against disposable PostgreSQL. The two tests for the pending case pin the old name; they passed before and after.
  • b91cebc80 (the rename): on a fresh disposable PostgreSQL 17 database, 11 unit tests failed first (KeyError on user_policy_associations, the missing revision, the privilege manifest, the recorded saved-policy SQL) and 4 lifecycle tests failed (NoSuchTableError public.user_policy_associations, user_policies still present). Then:
    • focused v2 unit tests passed (144) and the v2 lifecycle passed (7 passed, 1 expected failure, twice in a row);
    • alembic -c alembic-v2.ini check reported no new operations;
    • reconcile_v2_privileges.py apply --create-missing-roles and verify passed, then the identity, comparison-run, privilege and saved-policy mirroring integration tests (27 passed);
    • the MySQL v1 lifecycle (4) and the v1 policy and saved-policy dual-write integration tests (12) passed;
    • the make test set without coverage passed (3,398).
  • dcf53c748 (review follow-up, tests):
    • With a placeholder later revision added locally, the old revision test read the wrong file and failed; the new one passed.
    • has_table_privilege(role, table, 'SELECT, UPDATE') returned true for a role holding only SELECT, so each privilege is now checked on its own.
    • The old view test also passed on an empty database; the new one fails there.
    • Local, uncommitted checks that the new tests detect what they should: with a view added to the revision, the view test failed as a strict expected failure that passed; with ON DELETE SET NULL removed from the downgrade's report foreign key, the new downgrade cascade check failed.
  • 459b03111 (review follow-up, document only): the Stage 12A document.
  • On every commit: ruff format --check ., ruff check ., uv run --frozen --extra dev mypy, python scripts/run_quality_guards.py; python scripts/export_migration_contracts.py left the generated files unchanged.

No inline # noqa suppressions are added. A rules fixture (rules/fixtures/api.v2.runtime-privileges-declared/reads_good.py) changes its docstring to the new table name, so this PR requests a review from core-developers.

🤖 Generated with Claude Code

anth-volk and others added 4 commits October 22, 2026 17:05
…mands

The policy and user-association qualification commands counted saved
policies through the UserPolicy model. Both run before every v2 upgrade, so
once the model takes a new table name they would query a table the
database does not have yet.

The policy qualification counts only while its revision is pending, when
the table is still named user_policies, so it names that table. The
user-association qualification counts on every run, so it counts the
table under whichever name the database has: user_policy_associations once
renamed, otherwise user_policies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The saved-policy table now follows the naming of the other user-association
tables. Revision fa7fa1035444 was autogenerated from the renamed model. Its
one documented post-generation correction replaces the generated
create-and-drop pair, which would have deleted every saved policy, with an
in-place rename; each constraint named after the table is dropped under its
old name and created under the generated name, and the generated changes to
the two convention-named indexes and the three foreign keys that refer to
the table are kept around the rename. The composite listing indexes keep
their ix_user_policies_* names, as the other association tables keep their
short index names.

The privilege declarations and the head revision constant use the new
name. Dual-write's saved-policy copy goes through the model and follows it.
Unchanged: the route /v2/user-policies, the user_policy_id columns,
legacy_user_policy_mappings and the v1 Cloud SQL table user_policies.

Not included: a temporary view named user_policies for the release still
running during the deploy. A view is not autogenerated, so it needs a human
migration decision; a strict expected-failure test records the gap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The revision test finds fa7fa1035444 by its ID instead of through the
  head constant, so a later revision no longer makes it read the wrong
  file, and it checks the revision's parent through the script directory.
- The lifecycle test checks each privilege of the probe role on its own
  (has_table_privilege with a list is true when any one is held), and
  checks grants and delete cascades after the downgrade as well as after
  the upgrade.
- The test for the missing user_policies view now stores a saved policy
  and upgrades to head before reading it under the old name, and only a
  missing relation counts as the expected failure, so an empty database or
  a failure while setting up no longer satisfies it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merge table and the rule for Stage 10 mirror rows name the table by
its current name. The rollback and schema compatibility sections no longer
say head lets earlier releases run: until a view under the old name
exists, a release from before the rename fails every statement on saved
policies, from the schema upgrade until the new release takes traffic,
and after a rollback until the rename is downgraded. They also say that
downgrading the rename alone keeps every row, and that the Stage 12A
compatibility test now maps the renamed table and no longer proves the
parent release runs against head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk requested a review from a team October 22, 2026 13:56
@anth-volk
anth-volk added this pull request to stack #4011 October 22, 2026 13:56
@codecov

codecov Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.66667% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 87.95%. Comparing base (8b9272d) to head (459b031).

Files with missing lines Patch % Lines
...ata/v2/user_association_migration_qualification.py 85.71% 1 Missing ⚠️
Additional details and impacted files
@@                          Coverage Diff                          @@
##           v2plan/1f-121-repoint-saved-items    #4010      +/-   ##
=====================================================================
- Coverage                              87.95%   87.95%   -0.01%     
=====================================================================
  Files                                    259      259              
  Lines                                  16169    16174       +5     
  Branches                                2636     2636              
=====================================================================
+ Hits                                   14222    14226       +4     
- Misses                                  1275     1276       +1     
  Partials                                 672      672              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

This branch was successfully deployed

1 active 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.

Rename the v2 table user_policies to user_policy_associations

1 participant