Skip to content
Draft
2 changes: 1 addition & 1 deletion .github/workflows/v2-integration-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ jobs:
- name: Test v2 policy persistence and immediate v1 mirroring
run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_policy_persistence.py tests/integration/test_v1_policy_dual_write.py tests/integration/test_mysql_policy_dual_write.py tests/integration/test_v2_user_policy_mirroring.py tests/integration/test_v1_user_policy_dual_write.py
- name: Test signed-in identity provisioning and user associations
run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_user_identity_persistence.py
run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_user_identity_persistence.py tests/integration/test_v2_saved_policy_view.py
- name: Test comparison-run persistence and privilege reconciliation as the managed roles
run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_stage12_comparison_persistence.py tests/integration/test_v2_privilege_reconciliation.py
- name: Test v2 household persistence and immediate v1 copying
Expand Down
1 change: 1 addition & 0 deletions changelog.d/v2plan-1f-122-rename-user-policies.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The v2 table user_policies is renamed to user_policy_associations, matching the other user-association tables; its constraints and convention-named indexes take the new name. Saved policies, their v1 mappings and the reports that name them are kept. A temporary view named user_policies, with the runtime role's privileges, lets the release from before the rename keep reading and writing saved policies during the deploy and after a rollback; a later change removes it. The privilege reconciliation now finds, grants and verifies privileges on views. The route /v2/user-policies, the user_policy_id columns, legacy_user_policy_mappings and the v1 Cloud SQL table user_policies are unchanged.
52 changes: 50 additions & 2 deletions docs/engineering/skills/alembic-migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,10 @@ check the declarations:

`apply` grants each declared privilege a role lacks. It never revokes: it
reports privileges the file doesn't declare, and the projects' default
privileges, in the job summary. It grants nothing when a declared role or
table is missing. The roles themselves are provisioned outside the
privileges, in the job summary. It grants nothing when a declared role,
table or view is missing; a declared table must exist as a table and a
temporary view (`TEMPORARY_VIEW_TABLES`) as a view, so a relation of the other
kind counts as missing. The roles themselves are provisioned outside the
repository; `--create-missing-roles` is accepted only for the disposable test
database.

Expand All @@ -179,6 +181,7 @@ When a pull request adds or changes a v2 table, or a connector's use of one:
uv run python scripts/reconcile_v2_privileges.py apply --create-missing-roles
uv run python scripts/reconcile_v2_privileges.py verify
uv run pytest -q tests/integration/test_v2_user_identity_persistence.py \
tests/integration/test_v2_saved_policy_view.py \
tests/integration/test_v2_stage12_comparison_persistence.py \
tests/integration/test_v2_privilege_reconciliation.py
```
Expand Down Expand Up @@ -294,3 +297,48 @@ last dependent table is dropped. Its only post-generation correction drops the
nine generated `v2_*` enum types at the end of the baseline downgrade. The v2
Postgres lifecycle test covers empty upgrade, downgrade to base, and re-upgrade
so stale enum types cannot make the generated baseline non-reversible.

### API v2 saved-policy table rename

Revision `fa7fa1035444` was autogenerated after renaming the `UserPolicy`
model's table from `user_policies` to `user_policy_associations`, with its
constraints and convention-named indexes. 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 collided with the old table's indexes of the same
names. Its only post-generation correction replaces that pair with
`op.rename_table` and, for each constraint the generated table named after
`user_policy_associations` (primary key, unique, three checks and two foreign
keys), drops it under its old name and creates it under the generated name
with the generated definition. 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 v2 Postgres lifecycle test stores a saved policy with a v1 mapping and a
report that names it, and checks that the rows, the references, the
cascades, a table grant and every constraint and index name survive the
upgrade and the downgrade.

The revision also holds the only approved hand-written exception in the v2
chain, approved by a maintainer on 2026-10-22: after the rename it creates a
view named `user_policies` with every column of `user_policy_associations`,
and the downgrade drops the view before renaming the table back. The Public
API release still serving during a deploy, or after a rollback to it, names
the table `user_policies`; a single-table view is automatically updatable, so
that release keeps reading and writing saved policies through it. The
privilege manifest declares the view with the table's privileges
(`TEMPORARY_VIEW_TABLES` in `policyengine_api/data/v2/privileges.py`), and the
privilege reconciliation grants and verifies privileges on views as on tables.
Autogeneration does not reflect views, so `alembic check` and the head schema
validation ignore it. A later revision drops the view once no deployable
release names `user_policies`; until then, a revision cannot drop or change
the type of a `user_policy_associations` column the view shows without
dropping the view first.

Tests: `test_alembic_v2.py` allows exactly these two statements and no other
`op.execute` in any v2 revision. The lifecycle test checks the view's columns
after the upgrade and its absence after the downgrade, and runs the previous
revision's inserts, reads, updates and deletes through it.
`test_v2_saved_policy_view.py` runs today's saved-policy services, the
sign-in merge, report creation and the Stage 10 saved-policy copy as the
runtime role with the table named `user_policies`, so every statement goes
through the view.
74 changes: 54 additions & 20 deletions docs/migration/stage-12a-user-login.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ unauthenticated: a standard user's row is never locked.
| State | Outcome | What happens |
| --- | --- | --- |
| The identity has no user yet; the browser's unauthenticated user exists | `converted` | The `user_identities` row (issuer, subject, verified email) is inserted pointing at the browser's `users.id`; the user becomes `standard` with `primary_country = country_id`. The ID is unchanged, so every association stays owned. |
| The identity already belongs to standard user S; the unauthenticated user exists | `merged` | Every row of `user_policies`, `user_household_associations`, `user_simulation_associations`, and `user_report_associations` owned by the unauthenticated user moves to S with its ID, contents, and timestamps unchanged; then the unauthenticated user is deleted. |
| The identity already belongs to standard user S; the unauthenticated user exists | `merged` | Every row of `user_policy_associations`, `user_household_associations`, `user_simulation_associations`, and `user_report_associations` owned by the unauthenticated user moves to S with its ID, contents, and timestamps unchanged; then the unauthenticated user is deleted. |
| No unauthenticated user with that ID | `none` | The identity's user is resolved, or provisioned, as on any request. |
| The ID names a standard user (already converted, or anyone else's) | `none` | That user is never touched; the identity's user is resolved as on any request. |

Expand Down Expand Up @@ -194,8 +194,8 @@ Concurrency on Postgres:
`legacy_household_id`; with `legacy_household_id`, both are stored, and
it must be the copy of that v1 household when one exists.
- `user-simulations` and `user-reports` accept only legacy IDs in 12A.
- **Stage 10 mirror rows.** A `user_policies` row with a
`legacy_user_policy_mappings` entry returns 409 on PATCH and DELETE. The
- **Stage 10 mirror rows.** A saved-policy row (`user_policy_associations`)
with a `legacy_user_policy_mappings` entry returns 409 on PATCH and DELETE. The
mirror projection now also records `legacy_policy_id`.
- **Request bounds.** Every `/v2/me*` and `/v2/user-*` write rejects a body
over 256 KiB with 413. FastAPI reads and parses the whole JSON body before
Expand Down Expand Up @@ -324,7 +324,16 @@ from the final SQLModel metadata. It has no post-generation corrections.
"Rollback").

The models drop the `user_associations` relationships from v2 simulations and
reports. `V2_ALEMBIC_HEAD_REVISION` is `fbb7488ebe8a`.
reports. `V2_ALEMBIC_HEAD_REVISION` was `fbb7488ebe8a` when Stage 12A shipped.

Revision `fa7fa1035444` later renamed `user_policies` to
`user_policy_associations`, with its constraints and its convention-named
indexes, matching the other association tables; the composite listing
indexes keep their `ix_user_policies_*` names. The route `/v2/user-policies`,
the `user_policy_id` columns and `legacy_user_policy_mappings` are unchanged.
The bullet points above describe revision `fbb7488ebe8a`, when the table was
still named `user_policies`. "Schema compatibility" below says what the
rename means for earlier Public API releases.

### Reshape safety

Expand All @@ -347,15 +356,19 @@ and production and confirm both dormant tables are empty.

### Rollback

Roll forward whenever possible. The schema lets the previous Public API
revision run (see "Schema compatibility" below), but that revision serves
`/v2/user-policies` and `/v2/user-households` without authentication: any
caller can list a user's saved items by `user_id`, read, rename or delete one
by record ID, and create rows for any user. Rolling the application back
would therefore hand users' saved work to anyone.
Roll forward whenever possible. Since revision `fa7fa1035444` (see
"Schema compatibility" below), releases from before that revision use saved
policies through the temporary view `user_policies` until a later revision
drops it; after that, rolling the application back to such a release also
needs revision `fa7fa1035444` downgraded, which keeps every row (see below).
The release before Stage 12A also serves `/v2/user-policies` and `/v2/user-households`
without authentication: any caller can list a user's saved items by
`user_id`, read, rename or delete one by record ID, and create rows for any
user. Rolling the application back would therefore hand users' saved work to
anyone.

- **Before any user-owned record exists, an application rollback is safe
without a database downgrade.** Signed-out saving needs no configuration, so
without a Stage 12A database downgrade.** Signed-out saving needs no configuration, so
treat user-owned records as existing from the moment this revision serves
traffic in an environment.
- **Once any user-owned record exists, signed in or not, an application
Expand Down Expand Up @@ -389,17 +402,36 @@ would therefore hand users' saved work to anyone.
`/v2/user-policies` and `/v2/user-households` responses require a non-null
`policy_id` / `household_id`, so any response that includes a legacy-only
row (a v1 reference with no v2 copy) fails with 500.
- **A database downgrade is lossy.** It restores NOT NULL on `policy_id`,
`household_id`, `simulation_id`, `report_id`, `country`, and
`users.primary_country`, so it fails while legacy-only rows, any simulation
or report associations, or any unauthenticated user exist. Removing them
first deletes users' saved work.
- **Downgrading the rename alone is not lossy.** Downgrading revision
`fa7fa1035444` drops the temporary view and renames
`user_policy_associations` back to `user_policies` with every row,
reference and table grant.
- **A database downgrade of Stage 12A is lossy.** Downgrading revision
`fbb7488ebe8a` restores NOT NULL on `policy_id`, `household_id`,
`simulation_id`, `report_id`, `country`, and `users.primary_country`, so it
fails while legacy-only rows, any simulation or report associations, or any
unauthenticated user exist. Removing them first deletes users' saved work.

### Schema compatibility

The revision is additive (expand-only) for everything the previous Public API
revision maps: it drops no table or column, and it adds no required column
without a server default to any table that revision writes. The only new
**Since revision `fa7fa1035444`, earlier releases use saved policies through a
temporary view.** That revision renames `user_policies` to
`user_policy_associations` and creates a view named `user_policies` with every
column of the renamed table, the approved hand-written exception described in
`docs/engineering/skills/alembic-migrations.md` ("API v2 saved-policy table
rename"). A single-table view is automatically updatable, and the runtime role
holds on it what it holds on the table, so a release from before the rename
keeps reading and writing saved policies, during a deploy and after a
rollback: its `/v2/user-policies` routes, report creation that checks the saved
policies a report names, the sign-in merge, and the Stage 10 saved-policy
copy. `test_v2_saved_policy_view.py` runs those operations as the runtime role
through the view. Once a later revision drops the view, such a release fails
every statement on saved policies.

The rest of this section describes revision `fbb7488ebe8a` alone. It was
additive (expand-only) for everything the previous Public API revision maps:
it drops no table or column, and it adds no required column without a server
default to any table that revision writes. The only new
required columns are `country_id` on the simulation and report associations,
which that revision maps but never inserts into, and `users.user_type`, whose
server default makes every user the previous revision creates (its Stage 10
Expand All @@ -412,7 +444,9 @@ row with a NULL `primary_country` does not fail there.

The lifecycle test `test_stage_12a_schema_serves_the_previous_revision`
exercises this rather than only comparing columns. It reflects the schema at
the parent revision with SQLAlchemy and upgrades to head. Then, through the
the parent revision with SQLAlchemy and upgrades to head. Since the rename,
the parent's `user_policies` is head's temporary view of that name, so the
parent's statements on saved policies run through the view. Then, through the
reflected parent tables, it does the following for every table the parent
revision maps:

Expand Down
Loading
Loading