Skip to content

Give the Simulation Entrypoint its own simulation schema, migration chain and database connection - #898

Draft
anth-volk wants to merge 15 commits into
mainfrom
v2plan/1b-104-persistence-foundation
Draft

anth-volk wants to merge 15 commits into
mainfrom
v2plan/1b-104-persistence-foundation

Conversation

@anth-volk

Copy link
Copy Markdown
Contributor

Fixes #897

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

The Simulation Entrypoint gets its own Postgres schema, simulation, in the database that holds the Public API's v2 tables. After this deploys, the entrypoint has a database connection and an empty simulation schema at revision 0001; nothing reads or writes it yet. This is the first of four stacked PRs (simulations and reports, runs and their recording, and result tables follow).

  • Persistence library (libs/policyengine-simulation-persistence): schema and role names, the naming convention, a pooled runtime engine (psycopg 3, pre-ping, no server-side prepared statements, so the same URL works through Supabase's poolers) and a reader for the applied revision. A malformed URL is refused without repeating it.
  • One migration chain at migrations/, version table simulation.alembic_version. The schema is created only when it is missing (an operator may create it in advance), the schema creation and every pending revision run in one transaction, and a statement waits at most ten seconds for a lock. Revision 0001 grants the runtime role schema usage, revision reads and default SELECT/INSERT/UPDATE on later tables.
  • Migration job before each deploy (migrate_simulation_database in the reusable deploy workflow): it is the only job that receives SIMULATION_MIGRATION_DATABASE_URL, in one step's env:. Deployments from main run alembic upgrade head and then alembic current --check-heads; a deployment dispatched from any other branch only runs the head check, so it can never apply an unmerged revision. deploy_entrypoint runs only after the job succeeds.
  • Runtime URL and readiness: the deploy script mounts SIMULATION_DATABASE_URL as a pinned Secret Manager version on every deploy. GET /ready reports simulation_database {reachable, revision, expected_revision} without changing its status; the candidate check requires the two revisions to match, asking again while a cold candidate has not reached the database.
  • Deploy script: each runtime secret is now resolved in its own assignment. Before, an array assignment took its exit status from its last substitution only, so a failed lookup of an earlier secret was ignored and the revision deployed with an empty reference. The Stage 12 block uses its own wrapper.
  • Rules: sim.stage12.no-migration-chain is replaced by sim.persistence.one-migration-chain, executor-independent, no-modal-database-credentials (no database URL in any Modal secret, deployment, temporary app or source, except the comparison path's own STAGE12_DATABASE_URL), migrate-before-entrypoint and migration-cycle, each with proof fixtures. sim.deploy.workflow-shell-safety and sim.deploy.container-context-minimal are tightened and now have proofs too.
  • CI: a new Simulation database job runs the chain's upgrade, alembic check, downgrade and upgrade cycle and every database test on postgres:17, as a migrator role that is not a superuser; it is part of CI complete.

Environment variables

Variable Read by Provider How it reaches the job
SIMULATION_DATABASE_URL Entrypoint runtime Secret Manager, in the project named by SIMULATION_ENTRYPOINT_GCP_PROJECT_ID The deploy script resolves the secret named by SIMULATION_DATABASE_URL_SECRET_NAME to its newest enabled version and mounts it with gcloud run deploy --set-secrets
SIMULATION_DATABASE_URL_SECRET_NAME Deploy script GitHub environment variable (beta, prod) env: of the deploy step in deploy_entrypoint
SIMULATION_MIGRATION_DATABASE_URL Migration environment and script GitHub environment secret (beta, prod) The migration job declares environment: ${{ inputs.release_environment }}; both callers use secrets: inherit; only the "Apply simulation schema migrations" step's env: holds it
SIMULATION_MIGRATION_MODE Migration script Computed from github.ref Same step's env:
REQUIRE_CURRENT_SIMULATION_DATABASE_REVISION Candidate smoke script Literal true env: of the candidate check step
SIMULATION_DATABASE_READY_MAX_ATTEMPTS Candidate smoke script Optional, defaults to 6 Not set
SIMULATION_TEST_DATABASE_URL, SIMULATION_REQUIRE_DATABASE_TESTS Persistence tests in CI Literals Job env: of Simulation database, pointing at its postgres:17 service

Not verified from the repository: that the secret, the secret-name variable and the migrator secret exist in beta and prod; that the project is policyengine-simulation-entry; that only the runtime service accounts can read the runtime secret and that the deploy account can list its versions; that both roles exist; and that GitHub-hosted runners can reach the database. Each missing piece stops the deploy rather than being skipped.

Waits for

  • The operator step that creates the simulation_migrator and simulation_runtime roles, stores their URLs (the migrator URL as the beta/prod environment secret, the runtime URL in Secret Manager readable only by the runtime service accounts), sets SIMULATION_DATABASE_URL_SECRET_NAME, and grants the migrator REFERENCES on the Public API tables, in staging and production. Merging before it makes every deploy stop at the migration job.
  • Migrations in the simulation chain merge one at a time; the PRs stacked above this one each add the next revision.

Testing

Every commit was checked: persistence tests on PostgreSQL 17 with database tests required (39 passed at the tip), the entrypoint suite (709 passed), the executor's workflow tests, the rules tool's tests and python -m scripts.rules guard, all four quality guards, Ruff with the CI-pinned version, uv lock --check, and Pyright for the persistence library and the entrypoint.

🤖 Generated with Claude Code

anth-volk and others added 15 commits October 22, 2026 17:00
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>
@anth-volk
anth-volk requested a review from a team October 22, 2026 14:28
@anth-volk
anth-volk added this pull request to stack #901 October 22, 2026 14:29

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.

Give the Simulation Entrypoint its own simulation schema, migration chain and database connection

1 participant