Repository navigation
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #4162 +/- ##
==========================================
+ Coverage 92.14% 92.19% +0.04%
==========================================
Files 297 298 +1
Lines 16673 16917 +244
Branches 2719 2754 +35
==========================================
+ Hits 15364 15596 +232
- Misses 792 798 +6
- Partials 517 523 +6
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This was referenced Nov 3, 2026
Add simulation_runs, one row per run of a simulation with the catalog, country package and PolicyEngine.py versions it used, its status, Modal call and output file; and report_run_simulation_runs, the simulation runs each report run combines. Poverty and inequality belong to a simulation run, the other result tables name the two simulation runs they compare, report runs record their Modal call and the two results without a table, and household jobs calculate one household for one year under one policy. Revision 2ffd50f47a2b was autogenerated, with two documented enum-type corrections. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The simulation service and its Modal workers write simulations, reports, household jobs, runs and results; the Public API writes users, saved items, policies, households and the catalog. Revision 7a83a87d029a drops the 14 foreign keys from the first group to the second, keeping each column as a plain UUID that the writing service checks, and the models drop the ORM relationships across the two groups. Keys within a group stay. The revision is autogenerated with no corrections. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The simulation API's economy output carries fields with no column in the v2 result tables. Revision 7f43d2c7c3c6, autogenerated with no corrections, adds report_runs.spm_provenance and data_version, the congressional districts' winner, loser and no-change shares, and the UK areas' disclosure tier and interval bounds. The detailed budget names neither a program's entity nor whether it is a tax, so both become nullable in program_statistics, whose unique key treats a missing entity as equal. The revision-by-revision downgrade checks move from the v2 lifecycle test, which reached the test-file length limit, to the simulation runs integration tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pull request can now declare a database role before an operator creates it, by listing the role in ROLES_AWAITING_PROVISIONING. While a listed role is missing, reconcile_v2_privileges.py apply and verify skip it and name it in their output and job summary instead of failing the deploy; once the role exists, they grant and check its privileges. A missing role that is not listed still blocks every grant and fails verification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The privilege manifest declares two roles an operator will create, both awaiting provisioning so the deploy skips them until they exist: - policyengine_v2_simulation_service, the simulation API on Cloud Run, creates simulations, reports, their runs, report run members and household jobs (SELECT, INSERT), and reads the policy, household and catalog rows a run needs; - policyengine_v2_modal_worker, its Modal workers, updates simulation runs, report runs and household jobs, inserts results, and reads the simulation definitions and the rows a run needs. The Public API's runtime role reads the simulation tables and results, and writes none of them. New integration tests run these writes as each role, and CI runs them and the simulation runs migration tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A budget-window report run compares one pair of simulation runs per year, so each comparison result table's unique key now includes the pair after the report run. decile_impacts.decile_variable names the variable that groups households (UK wealth deciles share the income variable and entity), and poverty.filter_group names the group one filter variable selects (age: child, adult, senior); both join their table's unique key. Revision 2e3de7c572ed is autogenerated with no corrections. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A simulation and a household job may name a dynamic, which a run reads, so the simulation service and Modal worker roles get SELECT on dynamics. reports.user_id is the owner the Public API names and checks, since the simulation service can't read users. The manifest and the Alembic guide state that the workers' UPDATE is table-level. verify now names a skipped role in the job summary as apply does, the catalog publication test checks that the catalog rows a simulation and a report name survive republishing, and the guide's descriptions of the new revisions are corrected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A budget-window report run compares one pair of simulation runs per year, and each pair's economy output has its own labour supply response, cliff impact, SPM receipts and data release. Revision dce0caa329cc, autogenerated with no corrections, moves those four values from report_runs to the new report_run_comparisons, one row per report run and pair, which the Modal workers insert and the Public API reads. The report run relationships are named for the rows they hold (simulation_run_members, comparisons, report_run_memberships), the simulation-side integration tests share their SQL helpers, and the Alembic guide says what deleting a user now leaves in reports.user_id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
verify now writes one job summary section, names the roles it skipped, and says "Every other declared v2 table privilege is held" while it skips any, instead of claiming every privilege is held. New tests cover verify failing once a role awaiting creation exists without its privileges, and a Modal worker finishing a household job. The rules registry describes the skipped roles and the simulation API's role tests, and the Alembic guide notes that NULLS NOT DISTINCT needs PostgreSQL 15. Docstrings name PolicyEngine.py in full, and the report-run module says no managed role can run its row-locking operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
check_declared_privileges no longer takes the roles awaiting creation: verify asks skipped_roles once and checks the other declared roles. The report-run module's note names the two operations that lock reports rows, and the tests check that the worker's household job update changed its row and that verify names the skipped role as skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test pins the number of tables the v2 metadata registers; this change adds simulation_runs, report_run_simulation_runs and report_run_comparisons, so it is 45. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
force-pushed
the
v2plan/b1-1-simulation-runs
branch
from
November 3, 2026 22:39
c9c6eb6 to
d674032
Compare
anth-volk
added a commit
to PolicyEngine/policyengine-sim-api
that referenced
this pull request
Nov 3, 2026
The Public API's run tables changed under this work (PolicyEngine/policyengine-api#4162): the labour supply response, cliff impact, SPM receipts and data version moved from `report_runs` to a `report_run_comparisons` row per report run and compared pair, and the result tables gained the keys that hold the parts left out before. - A report run's completion inserts its `report_run_comparisons` row with its other result rows, in the same transaction as its success. - UK wealth deciles are stored in `decile_impacts`, with `decile_variable` `household_wealth_decile`; income deciles leave it null. - Poverty rates by age, gender and race are stored with .py's groups: `filter_variable` names the variable that selects a group (`age`, `is_male`, `race`) and `filter_group` the group; both stay null for everyone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The five revisions after c614b6bcccfc are deleted and revision 1356f658347d is autogenerated from that revision against the same models, so the schema at head is unchanged. Its two corrections are the ones the first of the five had: simulation_runs.status reuses v2_simulation_status, and the downgrade drops v2_simulation_role. The per-revision tests become one test of the revision's source and one downgrade-and-upgrade test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each service writes only its own tables, and the foreign keys between them stay, so PostgreSQL checks every row a simulation-side row names when it is written. The 14 keys from simulations, reports and household jobs to the Public API's tables are kept, with their ORM relationships, and the new columns that name the Public API's rows get keys with the delete rules the same columns have on simulations: a simulation run's catalog version and dataset (RESTRICT) and region (SET NULL), and a household job's household and catalog version (RESTRICT). reports.user_id gains an index for account deletion's SET NULL. Revision 1356f658347d is regenerated; its corrections are unchanged. The integration tests now store the catalog rows and household a row names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A report run compares one pair of simulation runs, so report_runs gains baseline_simulation_run_id (required) and reform_simulation_run_id (empty for a household report with only a baseline), each a foreign key to simulation_runs with RESTRICT. report_run_simulation_runs and its v2_simulation_role type are gone, and the comparison result tables' unique keys are per report run again. create_report_run takes the simulation runs a new run compares. Revision 1356f658347d is regenerated; its one correction left is simulation_runs.status reusing v2_simulation_status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
report_run_comparisons is gone: a report run compares one pair, so the parts of an economy comparison that had no result table belong to it again, typed. report_runs gains the baseline's and reform's cliff gap and share (checked to be set together), the SPM receipt as jsonb and the data release. The new labor_supply_responses holds one row per report run with the earnings, revenue and weekly hours responses, and labor_supply_decile_responses one row per report run and income decile. The Modal worker inserts into both and the Public API reads them. Revision 1356f658347d is regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Following the simulation API's budget-window design, without its scheduling columns: simulation_runs gains a required year, which joins the index for a simulation's newest successful run, so a budget window compares a report's same two simulations in every year. reports.budget_window_size (1 to 75 years) marks a budget-window report. budget_window_runs holds one row per calculation (report, catalog version, trigger, whether to recalculate, status, error and times), reusing the report run's status and trigger types, and report_runs.budget_window_run_id and year name the window run and year a report run calculates, at most one per year, kept to the same report by a composite foreign key. The simulation service creates window runs; the Modal worker and the Public API read them. create_report_run now takes its package versions and simulation runs as one ReportRunInputs, keeping it within ruff's argument limit. Three model tests that still asserted the dropped foreign keys and index set are updated. Revision 1356f658347d is regenerated with three corrections of one kind: new columns reusing existing enum types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The privilege manifest can now declare a privilege on some of a table's columns (update_columns), beside whole-table privileges. The Modal worker updates only a run's status, Modal call ID, error, times and result columns (a simulation run's output file, digest and household result, a report run's cliff impact, SPM receipt and data version, a household job's result), and the simulation service only the status, call ID, error and completion columns of the runs and household jobs whose Modal calls it starts. PostgreSQL then refuses any change to what a run is. apply reads column grants from pg_attribute, grants each declared column privilege a role lacks with one GRANT per column privilege, and reports undeclared column grants. verify checks each declared column with has_column_privilege and fails when a role also holds the privilege on the whole table, which would override the column limits. The two privilege rules and the role tests change to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
plan_reconciliation takes GrantedPrivileges, the managed roles' grants on whole tables and on columns, instead of a separate argument for each, so it stays within ruff's argument limit without a suppression. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the review round and a search for stale tests: - the decile unique key no longer includes the run pair; - a budget-window run's year runs are deleted with it (CASCADE) instead of blocking its deletion, so every foreign key names a delete rule, as test_every_table_has_named_primary_foreign_and_relational_constraints requires; deleting a report still removes both; - the run-definition columns no role may update include a report run's trigger and idempotency key and a household job's country and versions; - the two revision lifecycle tests are one downgrade-and-upgrade test, and the module docstring describes the final schema. Revision 1356f658347d is regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The models' reports module had 897 lines and privilege_reconciliation 810, over api.quality.file-length's 800. Code moves unchanged: - The result tables and their enums move from models.reports to the new models.run_results; reports keeps reports, budget-window runs and report runs. Both are imported from the models package as before. The package's new import line carries the same E402 suppression as its neighbours, with its reason, because the tables must be declared after the naming convention. Import Linter's model exceptions and ruff's runtime base classes name the new module. - The planning and reporting half of privilege_reconciliation moves to the new privilege_plans, which runs no SQL; privilege_reconciliation keeps the database access and the command line and re-exports the moved names. _safe, _skipped_roles_line and _verification_report, now used from a second module, become safe_identifier, skipped_roles_line and verification_report. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the second review round: - A role test checks, for the simulation service and the Modal worker, every column of simulation_runs, report_runs, household_jobs and budget_window_runs: an UPDATE of it succeeds exactly when the manifest declares it for that role. Seen failing with an undeclared column grant. - api.v2.deployed-privileges-match's scope names privilege_plans, which now holds the planning and verification code. - The Alembic guide's privilege paragraph keeps apply's sentences together. - The report runs module says complete_report_run sets markdown, which no managed role may update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The third review round found that the downgrade doesn't need household_jobs empty: it drops that table's new columns with their values. It needs simulations empty, which leaves no run or result row either. The revision's docstring and the Alembic guide say so; no operation changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
added a commit
to PolicyEngine/policyengine-sim-api
that referenced
this pull request
Nov 6, 2026
PolicyEngine/policyengine-api#4162's rebuilt schema changes three things this branch writes: - A report run names its two simulation runs, in `baseline_simulation_run_id` and `reform_simulation_run_id`, and `report_run_simulation_runs` is gone. `require_simulation_runs` reads those two columns: a report run that names other simulation runs raises ValueError, and a missing one LookupError. - `report_run_comparisons` is gone. The cliff impact is four columns of the report run, the SPM receipts its `jsonb` column and the data version its own column; the completion's `UPDATE` sets them with the run's success, so they roll back with it. The labour supply response is one `labor_supply_responses` row and one `labor_supply_decile_responses` row per decile, inserted with the other result rows. .py reports relative responses only for deciles above zero, so a decile without one has null relative columns. - The foreign keys between the two services' tables stay, so the tests' simulation runs name a real catalog version and dataset, and are inserted before the report run that names them. The completion's `UPDATE` sets only columns the Modal worker role may update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
added a commit
to PolicyEngine/policyengine-sim-api
that referenced
this pull request
Nov 6, 2026
…bles The models describe the head of PolicyEngine/policyengine-api#4162: report runs that name their simulation runs, budget windows, the 20 keys to the Public API's own tables and the service's column UPDATE privileges. At the old pin d674032a2 the comparison fails and finds none of the 20 keys. The job's last step fails until that pull request merges into the Public API's master with a merge commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 tasks done
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 #4161
Warning
Deploying this PR changes the staging and production v2 database schemas and table privileges. The release's v2 migration job adds four tables, moves the execution columns off
simulations, re-keys the result tables by run and adds the economy output's missing columns. It needs the changed tables empty, which they are while no managed role can write them (see "Data"). It also declares two new database roles, which an operator creates (see "What this waits for before merging"), and changes four rules' entries in the rules registry (the three privilege rules, and the allowed table-model imports ofapi.v2.pure-modules-no-databasefor the newrun_resultsmodule), so it needs a core developer's review.Summary
API v2's simulation tables recorded one execution per simulation. This PR makes every run a row, with the versions it used, so re-running a simulation or report keeps the earlier results, and prepares the tables for the simulation API (
PolicyEngine/policyengine-sim-api) and its Modal workers to write them.simulationskeeps only what to calculate. Its execution columns (status,error_message,started_at,completed_at,household_result) move to runs;output_dataset_idis replaced by the run's output file.simulation_runs(new): one row per run of a simulation for oneyear, with the catalog version (tax_benefit_model_version_id),country_package_versionandpolicyengine_versionit used, the dataset and region it resolved, its status (pending,running,succeeded,failed, reusing thev2_simulation_statustype), Modal call ID, error, timings,household_resultfor a household run, andoutput_uriwithoutput_sha256for an economy run's output file. Checks: a finished run hascompleted_at; the output URI and digest are set together; the digest is 64 characters; the year is 1900–2200. Indexes for the newest successful run of a simulation in a year on given versions, and for runs by status and start time.report_runs(already one row per run with both versions) names the two simulation runs it compares:baseline_simulation_run_id(required) andreform_simulation_run_id(empty for a household report with only a baseline), each a foreign key tosimulation_runswithRESTRICT. It gainsmodal_call_id, an index by status and start time, and the parts of an economy comparison that have no result table: the baseline's and reform's cliff gap and share (four numbers, set together or not at all), the SPM receipt (spm_provenance,jsonb) and the data release (data_version).labor_supply_responsesandlabor_supply_decile_responses(new): the labour supply response of a report run, one row per report run (the earnings changes from the income and substitution effects and their total, the revenue change, the two relative responses, and baseline, reform and changed weekly hours split into the two effects), and one row per report run and income decile (each decile's income and substitution responses, per household and relative to earnings).reports.budget_window_size(1 to 75 years; an economy report with a year, ending by 2200) marks a budget-window report.budget_window_runs(new) holds one row per calculation of one: its report, catalog version, trigger, whether its years calculate their simulations again (recalculate), status (reusingv2_report_run_statusandv2_report_run_trigger), error and times. Each year is a report run namingbudget_window_run_idandyear, at most one per year of a window run; a composite foreign key keeps the window run to the same report. What starts a window's later years is left to the simulation service's scheduling work.povertyandinequalitydescribe one simulation, so they belong to a simulation run (simulation_run_id, cascade). The other result tables (budget_summary,program_statistics,decile_impacts,intra_decile_impacts,congressional_district_impacts,constituency_impacts,local_authority_impacts) still belong to a report run and name the two simulation runs it compares (baseline_simulation_run_id,reform_simulation_run_id) instead of two simulations; each row is unique within its report run.aggregatesandchange_aggregatesare unchanged.decile_impacts.decile_variablenames the variable that groups households when it isn't the income variable's own deciles (UK wealth deciles:household_wealth_decile), andpoverty.filter_groupnames the group a filter variable selects (age:child,adult,senior), as PolicyEngine.py's outputs name them; both join their table's unique key, where missing values count as equal (NULLS NOT DISTINCT).congressional_district_impactsgains the winner, loser and no-change shares;constituency_impactsandlocal_authority_impactsgain the UK disclosure fields (disclosure_tier, checked to bereportedorapproximate, and the four_lower/_upperinterval bounds);program_statistics.entityandis_taxbecome nullable, because the detailed budget names neither. Every field of the simulation API's single-year economy output now has a column.household_jobscalculates one household for one year under one policy: it gainshousehold_id,year(1900–2200), the catalog and package versions,modal_call_id,updated_atand the same completion check;request_datanow holds the calculation's options, such as axes. Nullpolicy_idmeans current law.simulations,reports,budget_window_runs,household_jobs, the run tables and the results; the Public API writes users, saved items, policies, dynamics, households and the catalog. Each service writes only its own tables, and the foreign keys between them stay, so PostgreSQL checks every row a simulation-side row names when it is written. The new columns that name the Public API's rows get keys with the delete rules the same columns have onsimulations: a simulation run's catalog version and dataset (RESTRICT) and region (SET NULL), a household job's household and catalog version (RESTRICT), and a budget-window run's catalog version (RESTRICT).reports.user_idgains an index, which account deletion'sSET NULLsearches. No privilege is needed for the checks: PostgreSQL runs them with the referenced table's owner's privileges.create_report_runtakes the simulation runs a new report run compares, with its package versions, as oneReportRunInputs.simulation_runsis also a v1 Cloud SQL table name; the v2 metadata validation now treats it as a name the two databases share, as it doessimulations.No table is dropped. No route, request or response changes: nothing serves these tables yet.
Table privileges
policyengine_api/data/v2/privileges.pydeclares two new managed roles, and the Public API's runtime role's reads:policyengine_v2_runtime(the Public API)policyengine_v2_simulation_service(the simulation API on Cloud Run)policyengine_v2_modal_worker(its Modal workers)simulations,reports,budget_window_runssimulation_runs,report_runs,household_jobspolicies,dynamics,households,tax_benefit_models,tax_benefit_model_versions,regions,datasets,parameters,parameter_valuesaggregates,change_aggregates)UPDATEis granted per column. The privilege declarations can now name a privilege on some of a table's columns (update_columns(...)), beside whole-table privileges, and PostgreSQL refuses a statement that sets any other column:status,modal_call_id,error_message,started_at,completed_atandupdated_at, and its results: a simulation run'soutput_uri,output_sha256andhousehold_result; a report run's four cliff columns,spm_provenanceanddata_version; a household job'sresult.status,modal_call_id,error_message,completed_atandupdated_atof the runs and household jobs whose Modal calls it starts: it records a call's ID once Modal accepts the call, and fails the run when Modal refuses it.So neither role can change what a run is (its simulation or report, versions, dataset, region, year, simulation runs or budget-window run). The database doesn't limit a worker to its own run; the workers' code does.
reconcile_v2_privileges.py applyreads column grants frompg_attribute, grants each declared column privilege a role lacks (oneGRANT UPDATE (…)per role and table) and reports column grants the declarations don't name.verifychecks each declared column withhas_column_privilege, and fails when a role holdsUPDATEon a whole run table, which would let it set every column. The catalog writer and the Stage 12 role keep exactly their privileges.Declaring a role before it exists.
reconcile_v2_privileges.py applygrants nothing, andverifyfails, when a declared role is missing, so declaring these roles would otherwise fail every deploy until an operator creates them. Both roles are listed in a newROLES_AWAITING_PROVISIONING: while a listed role is missing,applyandverifyskip it and name it in their output and job summary ("Declared roles not created yet, skipped until an operator creates them"), andverifyreports "Every other declared v2 table privilege is held". Once the role exists, the next deploy grants its privileges andverifychecks them. A missing role that is not listed still blocks every grant and fails verification. A follow-up PR removes the two roles from the list once they exist in both environments.The rules registry's
api.v2.deployed-privileges-matchnow describes column grants, the whole-table check and the roles awaiting creation;api.v2.runtime-privileges-declaredrequires each column privilege to name columns of its table and never sit beside the same privilege on the whole table;api.v2.runtime-privileges-tested-as-runtime-rolenames the two simulation API roles.docs/generated/rules.mdis re-rendered.tests/integration/test_v2_simulation_role_privileges.pyruns the simulation API's writes as each role: the simulation service creates the rows and records or fails a call but can't set a run's start, output or definition; a worker updates its run's state and results and inserts results but can't create a simulation, change a result or change what a run is; the Public API reads but can't write. Another test checks, for both simulation API roles, every column of the three run tables andbudget_window_runs: anUPDATEof it succeeds exactly when the declarations name it for that role. CI's v2 integration job runs it, and the v2 Alembic job runstests/integration/test_alembic_v2_simulation_runs.py.Revisions
One revision,
1356f658347d(parentc614b6bcccfc, the currentmasterhead), autogenerated withuv run alembic -c alembic-v2.ini revision --autogenerateagainst a disposable PostgreSQL 17 database at its parent. It replaces the five revisions this PR had before; they were deleted in new commits, so the branch moves forward without a force-push.Its three post-generation corrections are of one kind, each commented in the revision and covered by a test: columns of new tables reuse enum types the baseline created. The generated
create_tablewould create each type again and fail (DuplicateObject), so each column usespostgresql.ENUM(..., create_type=False):simulation_runs.statusreusesv2_simulation_status, which the baseline created forsimulations.status;budget_window_runs.statusreusesv2_report_run_status;budget_window_runs.triggerreusesv2_report_run_trigger.V2_ALEMBIC_HEAD_REVISIONis now1356f658347d. The Alembic guide documents the revision and the new roles.NULLS NOT DISTINCTneeds PostgreSQL 15 or later.Data
The upgrade adds required columns without server defaults to
household_jobs,report_runsand the result tables, so it needs those tables empty. They are while no managed role holds a write privilege on them (onmaster, the runtime, catalog writer and Stage 12 roles hold none on any table this PR changes). On a table with rows the upgrade fails inside its transaction and the release stops before deploying; nothing is migrated or deleted. The downgrade restores required columns ofsimulationsand the result tables, so it works only whilesimulationsis empty, which leaves no run or result row either; it dropshousehold_jobs' new columns with their values.Compatibility with the previous Public API revision
The deploy upgrades the v2 schema before the new release starts, so the previous release runs against this revision for a while, and after a rollback. It can't read or write any table the revision changes: its managed roles hold no privilege on them, except the catalog writer, which counts the rows of
simulations,reportsandreport_runs(a count reads no column).tests/integration/test_alembic_v2_simulation_runs.pychecks that the revision adds its four tables and changes only the simulation-side tables, and downgrades it and upgrades it again. The runtime role's new reads only add privileges.Environment variables
This PR reads no new environment variable. The CI changes only add test files to existing steps of
alembic-v2-check.yml(through.github/scripts/test_alembic_v2_lifecycle.sh), whose jobenv:sets the database URL andV2_ALEMBIC_DISPOSABLE_TESTas literals, andv2-integration-check.yml, whose jobenv:also setsDEPLOYMENT_ENVIRONMENT. The existing v2 seed workflow (.github/workflows/seed-v2-database.yml, called frompush.ymlwithsecrets: inheritanddeployment_environmentstagingorproduction) applies the revision and the privileges. Its job runs in that GitHub environment and reads:V2_MIGRATION_DATABASE_URL: GitHub environment secret, present instagingandproduction; passed as stepenv:.V2_SUPABASE_PROJECT_REF,V2_SUPABASE_ENVIRONMENT: GitHub environment variables, present in both; jobenv:.PRODUCTION_V2_SUPABASE_PROJECT_REF: repository variable, present; jobenv:.DEPLOYMENT_ENVIRONMENT: the workflow input.GITHUB_STEP_SUMMARY: set by GitHub Actions.The two new roles' credentials are created and given to the simulation API outside this repository (below); until an operator confirms those steps, they are not verified.
What this waits for before merging
NULLS NOT DISTINCT: met. Supabase's project details give PostgreSQL 17 for both (staging 17.6.1.166, production 17.6.1.155; checked on 2026-11-06).reportsandsimulations: whichever merges second points its first revision'sdown_revisionat the other's head.policyengine_v2_simulation_serviceandpolicyengine_v2_modal_workerin the v2 database (no superuser, database, role-creation, replication or RLS-bypass attributes; no role membership; no table grants by hand), generates each password without printing it, and stores each URL in Secret Manager in the simulation API's project (simulation-service-staging-database-url,modal-worker-staging-database-url). Then the "Seed v2 database" workflow runs forstaging(by hand, or with the next release), which grants and verifies the two roles' privileges.…-production-database-url, workflow environmentproduction).ROLES_AWAITING_PROVISIONING, so that a missing role fails the deploy again.SIMULATION_SERVICE_DATABASE_URL(from the first secret, Connect the Simulation Entrypoint to the Public API's database with its own role policyengine-sim-api#898; the GitHub environment variable naming the secret isSIMULATION_SERVICE_DATABASE_URL_SECRET_NAME) and its Modal workers with the Modal secretpolicyengine-modal-worker-database(keyMODAL_WORKER_DATABASE_URL, from the second).pg_rolesandpg_auth_membersshow the roles' attributes and no memberships; the seed workflow's summary shows the roles' grants andEvery declared v2 table privilege is held;has_table_privilegeandhas_column_privilegespot checks (for example the Modal worker can updatesimulation_runs.statusbut notsimulation_runs.simulation_id, and holds noUPDATEon the whole table);gcloud secrets versions listshows each secret's enabled version.Testing
Each commit was written red-green: the tests first, seen failing for the expected reason, then the code. Only the tests each commit adds or modifies were run, each by node ID, against a disposable
postgres:17database namedpolicyengine_v2_alembic_test(set up asalembic-v2-check.yml's service container is), withalembic check, ruff, ruff format and mypy before each commit; the full suite runs in CI.Commits since
d674032a2(after mergingmasterat90b212b51, which added no v2 revision):aa2875f40(one revision). Seen failing: the revision chain test and the new revision source test. Passing: those two and the two integration revision tests.437560940(foreign keys stay). Seen failing: the six new key, index and refusal tests. Passing: 16, including the integration tests whose helpers now store the catalog rows and household a row names.d6693ad36(report runs name two simulation runs). Seen failing: 16 tests, and the two report-run unit modules at import. Passing: 38.41b22d179(typed results). Seen failing: 9 tests, and the model test module at import. Passing: 18, and the 18 report-run unit tests on SQLite with the new check.c4742f225(budget windows). Seen failing: 16 tests. Passing: 47.c6bb73a87(column-levelUPDATE). Seen failing: three test modules at import and the two role tests. Passing: 17.b990f6cb7(one argument for the database's grants). Passing: the 15 changed planning tests and the four changed integration tests.357719fad(stale tests). Seen failing: the decile key test and the two window-run key tests. Passing: 5.56212cc00(module split). Seen failing:api.quality.file-length(two files over 800 lines). Passing: the check, and all 79 tests this PR adds or changes.7642bd430(every run column). Seen failing: the new per-column role test, with an undeclared column grant added to the disposable database; passing once it was revoked.772ca6b14(the downgrade's stated precondition). Text only; the revision source test andalembic checkpass.On
772ca6b14, and again on a trial merge withmasteratf932adf71(which adds no v2 revision or model, and merges without conflicts):alembic checkreports no changes;lint-importskeeps all five contracts;python -m scripts.rules validate,python scripts/run_quality_guards.pyanduv lock --checkpass; ruff and ruff format are clean; mypy's configured scope (make typecheck-v2) is clean.policyengine_api/data/v2/is outside that scope. Run explicitly on the 13 changed modules there and the revision, mypy reports 48 errors, each of a kind thatmaster's versions of the same files already have (29 errors there): SQLModel'sFieldoverloads don't acceptsa_column, and autogenerated revisions referencesqlmodel.sql, asmaster's own revisions do.Three review rounds ran on the rework, plus a search for existing tests the schema change would break:
357719fadand56212cc00: the file-length limit, an existing test requiring every foreign key to name a delete rule (the budget-window key is nowCASCADE), and a stale decile key assertion. Round 1 also noted that no role may updatebudget_window_runsyet, left for the work that schedules a window's later years.7642bd430.772ca6b14and in this description.Each round regenerated the revision from
c614b6bcccfcagainst the models: its operations match, apart from order and the three commented corrections.Moved code and inline suppressions
policyengine_api/data/v2/models/reports.pyto the newrun_results.py, and the privilege planning and reporting code fromprivilege_reconciliation.pyto the newprivilege_plans.py, underapi.quality.file-length. The code is unchanged.privilege_reconciliationre-exports the moved names, and the models package still exports every model. Helpers now used from a second module were renamed:_safetosafe_identifier,_skipped_roles_linetoskipped_roles_line,_verification_reporttoverification_report.# noqa: E402on the models package's import ofrun_results, with its reason. Like the package's other imports, it must follow the naming convention the package sets before any table is declared, so it can't move to the top of the file.🤖 Generated with Claude Code