Skip to content

Add the one-time migration's provisioning command for migrated reports - #944

Draft
anth-volk wants to merge 34 commits into
v2plan/1b-105-simulations-and-reportsfrom
v2plan/2c-205-migration-provisioning
Draft

anth-volk wants to merge 34 commits into
v2plan/1b-105-simulations-and-reportsfrom
v2plan/2c-205-migration-provisioning

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #943

Same-Repository Draft PR Check

  • This PR is a draft.
  • This PR is from a branch in PolicyEngine/policyengine-sim-api, not a personal fork.

Summary

Third PR in the stack, on top of #898 and #900. It adds the command an operator runs once per environment during the one-time migration: it reads the Public API's export of v1 report definitions, creates each report's simulations in simulation.simulations and then the report in simulation.reports, with its year and options and no run, and writes a file mapping each v1 report ID to its v2 report ID. Nothing runs it yet, and it is not an HTTP route. A later item removes it.

cd libs/policyengine-simulation-persistence
uv sync --frozen --extra provisioning
uv run --frozen --extra provisioning \
  python -m policyengine_simulation_persistence.migration_provisioning \
  --definitions report-definitions.json (--output report-mapping.json | --dry-run)
  • Input format (defined here; the Public API's export will produce it). Report definitions, schema version 1: strict Pydantic models in migration_provisioning/definitions.py, documented with an example in that package's README. Each definition has the v1 report ID, kind, country, year, options, and its baseline and reform simulations, each with its v1 simulation ID and v2 references. Before anything is written, the file check rejects:

    • missing or unknown fields, and numbers given as text;
    • an economy report without a reform, and simulations of one report on different regions, datasets or households;
    • a v1 report listed twice, and a v1 simulation that two reports describe differently;
    • values the database cannot store: IDs beyond bigint, text holding a NUL character, and NaN or Infinity in the options.

    It builds the stores' NewSimulation and NewReport values, so the tables' own checks also run on every definition.

  • Provisioning. For each definition, simulations first (keyed by legacy_simulation_id), then the report (keyed by legacy_report_id), through SimulationStore.create_migrated and ReportStore.create_migrated:

    • An existing row is reused when it matches the definition, so the same export can run again and maps the same reports. A simulation several reports share is created once.
    • Values are compared as the exact decimals JSONB stores, so a float such as 1e23 in the options matches the row it became.
    • Before it creates any of a report's rows, a run reads every row the report needs that already exists and checks it against the definition, so a report that conflicts with an existing row creates nothing. That matters because the runtime role cannot delete rows.
    • A conflicting row, or a definition the database rejects (a policy or household the Public API does not have, one of another country, or another value it rejects as data), fails that report alone, with a one-line reason; the other reports continue. Each row commits on its own, so a report the database rejects after its baseline was created leaves that simulation, and a later run reuses it.
  • Dry run. --dry-run only reads. It counts the simulations and reports a run would create and those that already exist, and reports the same conflicts with existing rows. It cannot see the Public API's tables, so it does not find a missing policy or household; the run reports those.

  • Connection. The command reads SIMULATION_DATABASE_URL, the runtime role's URL the Simulation Entrypoint uses, and refuses to start unless it logs in as simulation_runtime to a database at the schema revision the code expects. Error messages never repeat the URL.

  • Output. A run writes the report mapping (schema version 1, migration_provisioning/mapping.py) to a new file only; it never overwrites one, and it checks that the directory can be written to before it connects. Both modes print a JSON summary. Exit status: 0 provisioned, 1 some reports failed, 2 refused to start (nothing written), 3 the database stopped it partway or the output file could not be written (running again reuses the rows).

Design choices

  • The export carries v2 references. Each definition already holds the v2 policy ID that legacy_policy_mappings maps its v1 policy to (null for current law), the v2 household ID from legacy_household_mappings, and the region code from the Public API's region reference files (Publish v2 catalog regions from checked-in reference files and the certified data release policyengine-api#4020). The Public API owns those tables and files, will create any missing v2 policies during the migration, and knows v1's older region spellings (ca for state/ca). So this command reads no Public API table and no reference file, and the runtime role needs no new grant: it keeps no privilege on the Public API's tables. The command checks a region code's form, not that the catalog has it; a code the catalog lacks would fail when the report is first calculated.
  • A v1 economy report with one simulation is calculated by v1 as that policy over itself, so the export names the same simulation as baseline and reform, and the report compares it with itself.
  • It lives in the simulation persistence library as the migration_provisioning subpackage, with Pydantic in a new optional provisioning extra (the migration job and the library's own package don't import it). That keeps it next to the stores it uses, and lets the existing Simulation database CI job run its PostgreSQL tests without workflow changes. Removing it later means deleting the subpackage, its tests, the extra and one README section.

Environment variables

  • SIMULATION_DATABASE_URL, read by the command in the operator's shell.
    • Provider: Google Secret Manager, the secret that sim's GitHub environment variable SIMULATION_DATABASE_URL_SECRET_NAME names (beta for staging, prod for production), in the project named by SIMULATION_ENTRYPOINT_GCP_PROJECT_ID (policyengine-simulation-entry in both, checked 2026-10-27), as the entrypoint deploy resolves it.
    • How it reaches the command: the operator reads it with gcloud secrets versions access and exports it in their own shell (the package README has the commands). No workflow, image or job passes it.
    • Not verified: SIMULATION_DATABASE_URL_SECRET_NAME is not set in either environment yet (checked 2026-10-27), so the secret could not be confirmed. Only the entrypoint's runtime service account is meant to read it, so the operator needs read access to it in each environment first.
  • The tests read the existing SIMULATION_TEST_DATABASE_URL and SIMULATION_REQUIRE_DATABASE_TESTS, set as literals in the Simulation database job.

No workflow, Dockerfile or infrastructure file changes.

Waits for

Testing

Every commit was built test first: the new tests failed for the expected reason, then passed with the code. Each commit then passed the checks below, run locally:

  • the persistence library's tests with a PostgreSQL 17 container and SIMULATION_REQUIRE_DATABASE_TESTS=1 (190 passed at the tip), and without a server (106 passed, 84 database tests skipped, as in the Test job);
  • the entrypoint's suite, which includes the database-access guard over libs/*/src (723 passed);
  • Ruff 0.15.22 format and lint, Pyright through python -m scripts.rules pyright --sync for the library, the quality guards, the rules tool's tests, and uv lock --check for both changed lock files.

Review: three review rounds and one extra round for the code added after them.

  • Round 1 found that a value the database rejects as data stopped the whole run instead of failing its report.
  • Round 2 found that a float such as 1e23 in the options failed the report it had just created, on every run, because JSONB stores it as an exact decimal; and that a mapping file that could not be written ended the command with a traceback.
  • Rounds 3 and the extra round found no bugs.

Each bug was fixed with a test that reproduced it first. The quality findings are fixed too: the file check rejects unstorable values, a conflicting report creates nothing, the output directory is checked first, the dry run's messages and the documentation are corrected, and test coverage, typing and test builders are improved.

Rebased onto #900 at its new head (2026-10-28, on main at 2a9212f) by segment 1B, without conflicts; every commit passed the fast checks (each test file collects, Ruff, the quality guards and the rules guard) and the head the full checks (190 persistence tests with the database required, and 797 entrypoint tests now that the branch includes main's). The checks above were run locally; this push's GitHub Actions runs report their own results.

🤖 Generated with Claude Code

@anth-volk
anth-volk added this pull request to stack #901 October 27, 2026 14:05
anth-volk and others added 28 commits October 28, 2026 00:35
Add libs/policyengine-simulation-persistence, the package that will hold the
Simulation Entrypoint's own Postgres schema, "simulation". This commit holds
only the names and the connection:

- the schema name, the runtime role name, the naming convention and the
  metadata every table of the schema registers in;
- the runtime engine, built from the canonical postgresql:// URL the
  deployment stores, with the psycopg 3 driver, a small pool, pre-ping, a
  connect timeout and no server-side prepared statements (so the same URL
  works through Supabase's poolers);
- a reader for the applied Alembic revision that needs no Alembic.

A malformed URL raises ValueError without repeating the URL or the parser's
message, since the URL holds a password.

The package is unit-tested in the PR test matrix and by make test,
type-checked by the Pyright job under sim.quality.pyright-libraries-and-entry,
and scanned by the database access guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace sim.stage12.no-migration-chain, which forbade any Alembic chain in
this repository, with ownership rules for the Simulation Entrypoint's own
"simulation" schema:

- sim.persistence.one-migration-chain: one Alembic chain at migrations/,
  configured by migrations/alembic.ini, keeps its version table at
  simulation.alembic_version, and no other Alembic configuration,
  environment or revision directory exists;
- sim.persistence.executor-independent: the Modal executor and gateway never
  declare, lock or import the persistence library;
- sim.persistence.no-modal-database-credentials: no Modal secret or
  deployment receives SIMULATION_DATABASE_URL or
  SIMULATION_MIGRATION_DATABASE_URL, so the comparison path's own database
  secret stays the only database credential Modal holds;
- sim.persistence.migration-cycle: on a fresh PostgreSQL, the chain upgrades,
  passes alembic check, downgrades and upgrades again as a non-superuser
  migrator, and the runtime role reads the revision but cannot change the
  schema.

The environment creates the schema only when it is missing, because
PostgreSQL checks CREATE on the database even for CREATE SCHEMA IF NOT
EXISTS; an operator may therefore create the schema in advance, owned by the
migrator. The schema creation and every pending revision run in one
transaction, so a failed migration leaves nothing applied. Revision 0001
grants the runtime role schema usage, revision reads and default DML
privileges on the tables later revisions create. Migrations run only online
and only with SIMULATION_MIGRATION_DATABASE_URL.

A new "Simulation database" PR job runs the database tests on postgres:17
and is part of "CI complete". The database access guard now also scans the
chain, which may import alembic and change the schema but still connects
only through SQLAlchemy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… check

The Simulation Entrypoint reads SIMULATION_DATABASE_URL, the runtime role's
URL for its own schema. When it is set, the app builds a pooled engine (no
connection opens until first use), disposes of it at shutdown, and GET /ready
reports the database's applied migration revision next to HEAD_REVISION, the
revision the image expects:

  {"status": "ready",
   "simulation_database": {"reachable": true, "revision": "0001",
                           "expected_revision": "0001"}}

The database does not change the readiness status, because no old-gateway or
Stage 12 route uses it yet: an unreachable or slow database (bounded at three
seconds) is reported as unreachable, logged with its error type only, and
/ready keeps following the old gateway. Without the setting, /ready keeps its
old shape. A URL that does not use the canonical postgresql:// scheme fails
validation without repeating the URL.

The entrypoint depends on the persistence library, so its image's build
context now includes it; a new test checks that every local library the
entrypoint installs is in the build context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each deployment (the main deploy and the Stage 12 deploy both use the
reusable workflow) now runs a migrate_simulation_database job after prepare.
It installs the persistence library's locked environment with the migrations
extra and runs .github/scripts/migrate-simulation-database.sh, which applies
the chain with alembic upgrade head and then requires the database to be at
the head. The job is the only one that receives
SIMULATION_MIGRATION_DATABASE_URL (a GitHub environment secret in beta and
prod), in its migration step's env alone. deploy_entrypoint needs the job and
runs only when it succeeded.

The deploy script now requires SIMULATION_DATABASE_URL_SECRET_NAME (a GitHub
environment variable in beta and prod) and mounts that Secret Manager secret's
newest enabled version as SIMULATION_DATABASE_URL on every deploy, whatever
the Stage 12 setting. The candidate smoke check sets
REQUIRE_CURRENT_SIMULATION_DATABASE_REVISION=true and then requires /ready to
report the database reachable and at the image's revision; the stable and
rollback checks do not, because an older image may face a newer schema.

The wiring is registered as sim.persistence.migrate-before-entrypoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The deploy script built its Cloud Run secret list in array assignments with
one command substitution per secret. Bash gives such an assignment the exit
status of its last substitution only, so with SIMULATION_DATABASE_URL added
last, a failure to resolve the old gateway's client secret (an invalid name,
a failed version listing, or no enabled version) no longer stopped the
script, and it deployed with an empty secret reference. The Stage 12 block
already hid failures for the two Modal token secrets the same way.

Each secret is now resolved in its own assignment, through
add_runtime_secret, so set -e stops the script at the first failure, before
gcloud run deploy runs. A test covers every runtime secret; three of them
deployed before this change. The test is registered under
sim.deploy.workflow-shell-safety.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sim.persistence.no-modal-database-credentials checked only the two
simulation URL names, and only in a Modal step's own env and run text. A
step could still give Modal any other database URL, such as one named
REPORTS_DATABASE_URL or a written-out postgresql:// URL, and a value set in
the job's or the workflow's env reached the step without being checked.

The check now finds any variable named like *DATABASE_URL* and any
written-out PostgreSQL URL in the workflow, job and step environments of a
step that creates a Modal secret or deploys a Modal application, in the
scripts such a step runs, and in the executor's and gateway's sources. Only
the comparison path's STAGE12_DATABASE_URL, and the name of the secret that
holds it, may reach Modal until the comparison path is removed. The rule's
statement and the library README say so, and the new tests are registered
under the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Modal credential check required at least one character before
DATABASE_URL, so a Modal step or a Modal application source using a
variable named exactly DATABASE_URL passed it, although the rule allows no
database URL in Modal except the comparison path's. The pattern now matches
the bare name, and the tests cover it in a workflow step and in a source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e head

The Stage 12 deploy workflow can be dispatched from any branch, and it uses
the same reusable workflow, so it would have applied that branch's
unmerged revisions to the beta or prod database; every later deploy from
main would then fail, because the applied revision is not in main's chain.

The migration step now sets SIMULATION_MIGRATION_MODE from the ref:
upgrade on main (alembic upgrade head, then current --check-heads), verify
elsewhere (only current --check-heads). The script refuses a missing or
unknown mode. Its unused SIMULATION_ALEMBIC_CONFIG override is gone.

alembic current --check-heads first appeared in Alembic 1.17.1, so the
persistence library's floor rises from 1.16 to 1.17.1 (the lock already
pins 1.20.0). A new PostgreSQL test runs the script itself against a fresh
database: verify refuses it, upgrade migrates it to the head, and verify
then passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fix that resolves each runtime secret in its own assignment introduced
add_runtime_secret and used it for the main path's secrets and for the
Stage 12 comparison path's Modal token and database secrets alike. The
comparison path keeps its own mechanisms, so it can be removed without
touching the main path: the Stage 12 block now uses add_stage12_runtime_secret,
its own copy with the same set -e behavior. A test checks that each block
uses only its own helper; it is registered under
sim.deploy.workflow-shell-safety.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/ready waits at most three seconds for the database, while a new
connection may take up to five, and the candidate smoke read /ready once.
A cold candidate whose first connection was slow therefore failed the whole
deployment. When the candidate check requires the database revision and
/ready reports the database unreachable, the smoke now asks again, up to
SIMULATION_DATABASE_READY_MAX_ATTEMPTS times (six by default), with the
usual retry delay. A database that is reachable but at another revision
still fails at once. The fake curl in the deployment tests can now serve a
sequence of /ready bodies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bad examples

Both rules were registered without proof, so the rules page listed them as
unproven.

- sim.persistence.migration-cycle: migration_cycle_failures runs a chain's
  upgrade, optional alembic check, downgrade and upgrade, and reports the
  first step that fails. The chain test uses it with the check, and the
  proof runs two fixture chains on PostgreSQL: one whose downgrade undoes
  its upgrade passes, and one whose downgrade leaves its table behind fails
  on the second upgrade.
- sim.persistence.migrate-before-entrypoint: migration_wiring_violations
  checks a deploy workflow's jobs: the entrypoint waits for a successful
  migration job, the migrator URL reaches only the migration step, and that
  step applies revisions only from main. The deploy workflow passes it; the
  proof's bad fixture fails all three checks and the good fixture passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each migration runs in one transaction, and adding a foreign key to a
Public API table (the next revisions do) takes a lock on that table. A
migration waiting behind a long Public API transaction would hold its place
in the lock queue, and every Public API write to that table would queue
behind it until the migration got its lock. The migration environment now
sets lock_timeout to ten seconds for its transaction, so such a migration
fails, applies nothing, and stops the deploy; the next deploy tries again.
A PostgreSQL test holds a lock the migration needs and checks that the
upgrade fails with a lock timeout instead of waiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch tightens both rules, which had no proof:

- sim.deploy.workflow-shell-safety now also requires every runtime secret
  to be resolved in its own assignment. combined_secret_resolutions finds a
  Bash array assignment that resolves more than one secret, where an earlier
  failure would pass unnoticed. The deploy script passes it (a new test,
  registered under the rule); main's script before this branch fails it,
  and so does the proof's bad fixture.
- sim.deploy.container-context-minimal now also requires every local
  library the entrypoint installs to be in its build context.
  libraries_missing_from_context compares a project's path sources with its
  .dockerignore; the existing test uses it, and the proof's bad fixture
  leaves one library out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… failed

- The Modal credential check counted only `modal secret create` and
  `modal deploy` as reaching Modal. A step that starts a temporary app with
  `modal run` (the precompute and smoke scripts do) was not examined; it is
  now. The current workflows still pass.
- The migrator-URL checks read job and step env but not workflow-level env,
  which reaches every job. secret_carriers now counts it, and the
  deployment test uses the same scan instead of its own copy.
- migration_cycle_failures reported only the exception class, so a CI
  failure showed neither the database error nor the detected differences.
  It now adds the error's first line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The persistence README's test commands and the Alembic configuration's
  example now use --frozen, as every install and test run must.
- DEPLOYMENT.md said the candidate smoke asks again "up to six times"; the
  default of six is six requests in all.
- The deploy script's comment on the Stage 12 wrapper now says what is
  separate (the wrapper) and what both blocks still use (the secret
  lookups that predate the split).
- The migration-cycle and migration-script tests read the applied revision
  through one helper in migration_support instead of two copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A simulation is one policy applied to one population: a household, or an
economy named by a region code (state/ca) with an optional dataset name,
where a null dataset name means the region's default dataset in whichever
catalog version a run uses. A null policy means current law. The row holds
no catalog version, so the same simulation can run again on a newer one; each
run resolves the region and dataset in its own version (item 1.06).

Revision 0002 creates the table with:

- the population checked against the kind (a household simulation has a
  household and no region or dataset; an economy simulation has a region
  and no household);
- foreign keys into the Public API's tables in the same database, all
  ON DELETE RESTRICT: (policy_id, country) to policies (id, country_id),
  (household_id, country) to households (id, country_id), and dynamic_id to
  dynamics, with an index on each referencing column;
- a unique legacy_simulation_id for simulations migrated from the Public
  API's v1 database, and a unique (id, country, kind) for the reports'
  foreign keys;
- no UPDATE privilege for the runtime role, since definitions never change.

Creating the foreign keys needs the operator's REFERENCES grant on the
Public API tables; a test shows the chain fails without it and leaves
nothing applied. SimulationStore creates, reads and finds simulations by v1
ID, and create_migrated is idempotent by legacy_simulation_id for the
one-time migration (item 2.05). Importing the package now registers every
table, so the migration environment always compares the whole schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A report is a definition: its baseline simulation, its reform simulation
(required for an economy report; a household report may have only a
baseline), the year (1900 to 2200) and its options as a JSON object. Composite
foreign keys (simulation id, country, kind) into simulation.simulations make
both simulations share the report's country and kind; they are ON DELETE
RESTRICT, so a simulation a report uses cannot be deleted. A report migrated
from the Public API's v1 database keeps its v1 report ID in the unique
legacy_report_id.

Revision 0002 now creates the reports table too, and revokes UPDATE on both
definition tables from the runtime role. ReportStore creates, reads and finds
reports by v1 ID; create_migrated is idempotent by legacy_report_id, for the
one-time migration (item 2.05). NewReport checks the country, year, options
and v1 ID before any write; the database checks them again for any other
writer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NewSimulation and NewReport typed kind as an enum but neither checked nor
converted it, so a kind given as plain text (as a mapping of v1 records
easily does) passed their checks and then failed with AttributeError when
the store read kind.value. Both now convert the kind to its enum member
when they are created, and reject an unknown kind with a ValueError that
names the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The database repeats each check NewSimulation and NewReport make, for any
other writer, but only the report year and options checks had a test, so a
mistake in another check's expression would have gone unnoticed. New tests
insert rows directly as the runtime role and expect each of the other
checks to refuse them: the kinds, countries, region code, dataset name,
both v1 IDs, and an economy report without a reform.

The README's roles table still gave the runtime role UPDATE on every table,
and did not say the migrator needs REFERENCES on the Public API tables
before revision 0002 deploys; both are corrected. The unused
REFERENCED_PUBLIC_API_TABLES constant is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Revision 0002 passed full check constraint names, such as
ck_simulations_kind, which the naming convention prefixed a second time,
so the database held ck_simulations_ck_simulations_kind while the table
definitions name it ck_simulations_kind. alembic check does not compare
check constraints, so nothing noticed; a later revision dropping one by its
defined name would have failed. The revision now marks the names as final
(op.f), and a test compares every constraint name in the migrated database
with the names the table definitions give.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oning

Add migration_provisioning to the simulation persistence library, with the
input format the one-time migration provisioning command reads: schema
version 1, one definition per v1 report, holding the v2 policy and household
IDs and region codes the Public API's export resolves. The models check, before
anything is written, that every field is present, that each report has the
simulations of its kind on one population, that each v1 report appears once,
and that a v1 simulation shared by several reports is described the same way.
They build the stores' NewSimulation and NewReport values, so the tables' own
checks run on every definition.

Pydantic comes from a new provisioning extra, so the migration job and the
library's own package do not need it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
provision_reports creates each definition's simulations, keyed by their v1
simulation IDs, and then its report, keyed by the v1 report ID, through the
stores' create_migrated methods, with no run. It reuses a row that already
exists when it matches the definition, so the same export can run again and
creates nothing new, and it creates a simulation that several reports share
once. A row that exists with a different definition, or a definition the
database rejects, fails that report alone with a one-line reason, and the
other reports continue. The result maps each v1 report to its v2 report and
counts new and existing rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With dry_run, provision_reports creates nothing. It counts the simulations and
reports a run would create and those that already exist, maps the reports that
already exist, and reports the same conflicts with existing rows as a run,
including a report already migrated with simulations the run would create. It
cannot read the Public API's tables, so it does not find a policy or household
the database would reject.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ort mapping

python -m policyengine_simulation_persistence.migration_provisioning reads the
export with --definitions and either writes each v1 report's v2 report ID to a
new --output file, or, with --dry-run, writes nothing. Before it touches a row
it checks the definitions file (listing up to 20 errors), that the output file
does not exist yet, and that SIMULATION_DATABASE_URL logs in as the runtime
role to a database at the revision this code expects. Both modes print a JSON
summary. The exit status says whether every report was provisioned, some
failed, the command refused to start, or the database stopped the run
partway; a stopped run writes no output file and running it again reuses the
rows already created. Error messages never repeat the database URL.

The README describes how an operator runs it, where the runtime role's URL
comes from, and the output file's format, which the Public API's migration
command reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A value the database rejects as data rather than as a constraint, such as text
holding a NUL character in a region code or in the options, raised past the
provisioning and stopped the whole run, and every later run stopped at the
same definition. It now fails that report alone, as a constraint violation
does. Legacy IDs above the bigint range of their columns are rejected with the
file's other errors, before anything is written.

The README now also says that `latest` names the newest secret version, which
may not be the newest enabled one the deploy mounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JSONB keeps a number as an exact decimal, so options holding a float such as
1e23 read back as the integer 100000000000000000000000. The check that an
existing row matches its definition compared that integer with the float, so
the run created the report, then reported it as already migrated with
different options, left it out of the mapping, and did the same on every
later run. Values now compare as the exact decimals the database stores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tten

The output file is written after every row is committed. When it could not be
written, for example because another process created it after the starting
check, the error escaped the command with a traceback, no summary and exit
status 1, which means some reports failed. The command now prints the summary,
says the run finished without an output file and that running it again with a
new --output writes the mapping, and exits with 3, the status for a run that
stopped before writing its output file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A NUL character in a region code, a dataset name or the options, or NaN or
Infinity in the options, passed the file check and the dry run, and failed
only in a real run, after the report's earlier simulations were committed.
The file check now rejects them with the file's other errors, before anything
is written. Any other value the database rejects as data still fails its
report alone; that test now injects the error from each store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 6 commits October 28, 2026 01:01
When the database stopped a dry run partway, the command said the rows
created so far were kept, although a dry run creates none. It now says the
dry run wrote nothing. The exit-status descriptions cover a dry run too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The report step now branches on the dry run instead of inferring it from
missing simulation IDs, the report definition base declares its abstract
methods, and the existing-row check has typed overloads for simulations and
reports. The definitions that refer to the test database's rows move into the
support module with annotations, so both test modules use the same ones. Two
tests are added: a later run reuses the simulations a failed report left, and
a dry run that finds conflicts exits with status 1. No behavior changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Importing the package's __main__ module ran the command, so a check that
imports every module, such as the runtime-evaluated-models guard, would have
started it. It now runs only as a program. The repository's guard imports
other packages, so a test in this library imports each provisioning module and
rebuilds every Pydantic model it defines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rectory first

A run created a report's baseline before it read the reform's existing row,
so a reform that conflicted left a baseline row behind for good, because the
runtime role cannot delete rows. A run now reads every existing row a report
needs and checks it against the definition before it creates anything, as
the dry run does, so a report that conflicts creates nothing; the dry run no
longer counts that report's missing simulations as ones a run would create.
The command also refuses an output directory it cannot write to before it
connects, instead of finding out after every row is created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The command's parsed arguments are now a frozen dataclass whose output path is
None exactly for a dry run, instead of an untyped namespace. The README no
longer says a NUL character fails only its report (the file check rejects it),
and it gives the 255-character limit of region codes and dataset names and
says that option numbers are read as integers or 64-bit floats. Tests now
parse the README's two examples against the formats and dry-run an existing
single-simulation economy report, and a misnamed command test is renamed. No
behavior changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One method now adds a report to the mapping and counts it as new or existing,
and the baseline and reform IDs are split out by a named helper instead of by
list index where each report is built. The command's docstring lists the
output directory check, and the model test's docstring says which half of the
runtime-evaluated-models guard it covers. No behavior changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk force-pushed the v2plan/2c-205-migration-provisioning branch from cf584fd to 3cda141 Compare October 27, 2026 21:27

This branch has not been deployed

No deployments
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.

Add the one-time migration's provisioning command for migrated reports

1 participant