Repository navigation
Connect the Simulation Entrypoint to the Public API's database with its own role - #898
Conversation
d5d1f0e to
c75c444
Compare
c75c444 to
6c58a36
Compare
|
Review at Verdict: a safe first step. It adds no tables and no data, the migration reverses cleanly, and only
Checked and fine: one transaction covers the schema creation and all revisions; What I ran: fresh clone, 🤖 Generated with Claude Code |
6c58a36 to
433f44e
Compare
433f44e to
874e15d
Compare
Add libs/policyengine-simulation-persistence, the package through which the simulation service (the Simulation Entrypoint) connects to the Public API's Postgres database with its own role. This commit holds only the connection: 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 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>
…its database
The Simulation Entrypoint reads SIMULATION_DATABASE_URL, its own role's URL
for the database that holds the Public API's tables. 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 whether a pooled connection answered a
trivial query:
{"status": "ready", "simulation_database": {"reachable": true}}
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>
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 in the Stage 12 block a failure to resolve either Modal token secret (an invalid name, a failed version listing, or no enabled version) did not stop the script, and it deployed with an empty secret reference. Each secret is now resolved in its own assignment, so set -e stops the script at the first failure, before gcloud run deploy runs: the main path's secrets through add_runtime_secret, and the Stage 12 comparison path's through add_stage12_runtime_secret, its own copy with the same behaviour, so it shares no deploy helper with the main path and is removed with it. A test covers every runtime secret (both Modal token secrets deployed before this change), and another checks that each block uses only its own helper. Both are registered under sim.deploy.workflow-shell-safety. 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>
The deploy script now requires SIMULATION_DATABASE_URL_SECRET_NAME (a GitHub environment variable in beta and prod, passed in the deploy step's env) and mounts that Secret Manager secret's newest enabled version as SIMULATION_DATABASE_URL on every deploy, whatever the Stage 12 setting. The secret holds the simulation service role's URL for the database that holds the Public API's tables. It is resolved like the other runtime secrets, in its own assignment, so a missing or invalid name, or a secret with no enabled version, stops the deploy before gcloud run deploy runs. The deployment document describes the wiring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…readiness check
The persistence library now lists the table privileges the simulation
service's role needs on the Public API's tables (SERVICE_TABLE_PRIVILEGES):
SELECT and INSERT on the tables it creates rows in (simulations, reports,
simulation_runs, report_runs, report_run_simulation_runs and
household_jobs), and SELECT on the policy, household and catalog tables a
run reads. missing_table_privileges asks PostgreSQL, in one query, which of
them the connected role lacks; a table missing from the public schema lacks
all of its privileges, and a table of the same name in another schema on the
search path doesn't count.
GET /ready reports the result next to whether the database was reached:
{"status": "ready",
"simulation_database": {"reachable": true, "missing_privileges": []}}
missing_privileges is null when the database was not reached. Neither field
changes the readiness status.
Those tests need PostgreSQL, which the Test job doesn't provide, so a new
Simulation database job runs the library's postgres-marked tests on
postgres:17 and fails rather than skips without the server. CI complete
needs it. The new rule sim.persistence.missing-privileges-reported, in a new
persistence category, records the check, with a proof.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…role needs The candidate smoke check now sets REQUIRE_SIMULATION_DATABASE_ACCESS=true and then requires /ready to report the simulation service's database reached with no table privilege missing, which shows that the role's URL reached the revision and that the role holds its grants. A failure names what /ready reported, such as "INSERT on simulation_runs". /ready waits at most three seconds for the database, while a new connection may take up to five, so a cold candidate whose first connection is slow is asked again while it reports the database unreachable, up to SIMULATION_DATABASE_READY_MAX_ATTEMPTS requests in all (six by default), with the usual retry delay. A reached database with a missing privilege fails at once. The stable and rollback checks don't set the requirement, because an older image may not use the database. The fake curl in the deployment tests can now serve a sequence of /ready bodies, and the new tests are registered under sim.deploy.smoke-tests-strict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ruff's formatter, at the pinned version, rewraps two calls in the test modules this branch added. No behaviour changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s database The persistence library now describes, with SQLAlchemy Core in service_metadata, the tables the simulation service creates rows in: simulations, reports, simulation_runs, report_runs, report_run_simulation_runs and household_jobs. Each model matches the table the Public API's v2 migration chain builds, column for column (type, nullability, server default), with every constraint and index under the chain's name. Foreign keys stay between these tables, because another service writes the policy, household and catalog rows they name. Status, trigger, role and type columns use StrEnum classes and store each member's value. The models only describe the tables: runtime code never creates or changes them. Tests marked public_api_schema run as the service's role (policyengine_v2_simulation_service) in a database the Public API's chain built with its declared grants, named by PUBLIC_API_SCHEMA_DATABASE_URL: the role lacks none of the privileges the service needs, and creates and reads back a row in every modelled table, in a transaction rolled back afterwards. Without the database they skip. The database-access skill now says that the Public API's migrations own every table this repository uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing the database unreachable missing_table_privileges found each table with to_regclass, which raises "permission denied for schema" for a role without USAGE on public. The readiness check caught that as a failure to reach the database, so a reached database with a grant missing was reported as unreachable, and the candidate check asked again six times before failing with a message about connectivity. Tables are now found through PostgreSQL's catalog (pg_class and pg_namespace), which any role may read, and a missing USAGE privilege on the schema is named first, as "USAGE on schema public". The rule's statement says so, and the new test is one of its enforcers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wn check Settings._validate_simulation_database checked only the URL's scheme, while the engine also refuses a URL without a user name, host or database, or with surrounding spaces. The settings now ask normalized_database_url, so they accept exactly the URLs the engine does, and say why one is invalid without repeating it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…upport module The database deployment tests imported cloud_run_deploy_environment from another test module. It now lives in test_deployment_assets_support with the other shared deployment helpers, and both modules import it from there. No test changes what it checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The OpenAPI test's comment still spoke of the database's revision, the persistence tests' conftest named a script this change does not add, and AGENTS.md's summary of PR CI did not mention the PostgreSQL tests. The deployment document now says what each environment needs, and in which order, before the first deploy that carries the connection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t line combined_secret_resolutions took a ) anywhere on an array assignment's first line as its end, so an assignment whose first element resolved a secret on that line, with a second on the next, was read as one line and passed. The assignment now ends on its first line only when that line's parentheses balance. The new test is one of sim.deploy.workflow-shell-safety's enforcers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eport_run_comparisons The Public API's revision dce0caa329cc moves labor_supply_result, cliff_impact_result, spm_provenance and data_version from report_runs to a new report_run_comparisons table, one row per report run and compared pair, which the Modal workers write and the simulation service doesn't. The report_runs model drops them, so it again matches the Public API's table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… check fully The enum test now also checks the type the helper builds before a table adopts it, so it fails under the persistence library's own SQLAlchemy 2.1 when the helper stops naming public, not only under the image's 2.0. The stalled-database test checks that only the first of three checks ever ran, and the dispose test that a started worker exits. "reachable": false is described as any failed check (not reached, login refused, no answer in time), the smoke script's retry message says so, the persistence readme says statement_timeout ends each statement of the check, and SimulationDatabase.dispose says the database is single-use afterwards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… readiness wording A test now shows that /ready reports a disposed database's check as failed, and it fails if dispose leaves the check's thread running. The readiness docstrings, the smoke script's comment and the deployment document no longer speak of a database that was not reached where any failed check is meant, and the time limit is described per statement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
874e15d to
21760a9
Compare
…E_DATABASE_URL The entrypoint now reads its role's URL from SIMULATION_SERVICE_DATABASE_URL, and the deploy step resolves the Secret Manager secret named by the SIMULATION_SERVICE_DATABASE_URL_SECRET_NAME GitHub environment variable. The new name says whose connection it is. A value under the old name is ignored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… log what is missing /ready's simulation_database now reports reachable and has_required_privileges instead of naming each missing privilege, because the route is public and unauthenticated. The service writes the names to its own log in simulation_database_missing_privileges messages, split so the observability runtime keeps each whole, and an unreachable database's error type in a simulation_database_unreachable message. That runtime's log handler keeps a record's message but drops its extra fields, so both are in the message. The candidate smoke check requires both answers to be yes and, when either is not, points to the revision's Cloud Run logs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/ready is public, so each request ran the privilege check and took a pooled database connection. A check that reached the database now answers every request for 30 seconds, and logs any missing privilege once. A failed check isn't reused, so a cold instance that reaches its database a moment later says so at once, as the candidate smoke check's retries expect. Requests that arrive while a check runs share it, and one that stops waiting leaves it running for the others. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rsion The Simulation Entrypoint's image ships SQLAlchemy 2.0, but the library's own lock resolved 2.1, so its tests ran on a release the service never uses. The library now requires sqlalchemy>=2,<2.1, its lock resolves 2.0.57, and a test keeps both locks on the same minor version. The cap comes off when the entrypoint moves to 2.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ervice's own log limit A failed readiness check logged only the wrapper's type, OperationalError, for a refused login, an unknown host and a statement timeout alike. It now also names the driver's error and its SQLSTATE code when the database sent one, never the error's text. Missing privileges are split at the limit of the observability runtime the service logs through, rather than at the library's default. The readiness schema and dispose docstrings now say a check that reached the database answers for 30 seconds, and the privilege rule no longer claims the logging, which its proof doesn't cover. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves three conflicts: CI complete needs the Simulation database job alongside main's jobs (main moved the changelog fragment check to its own workflow), AGENTS.md describes both, and the rules document is re-rendered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Public API's new revision drops report_run_simulation_runs: a report run now names its baseline and reform simulation runs, and carries its cliff impact, SPM receipt and data version. A report's budget window is calculated as a budget_window_runs row with one report run per year, and each simulation run records its year. The service creates budget window calculations and reads dynamics, so its required privileges follow. The role-write test inserts the catalog, dataset and household rows its rows name first, since the Public API's keys check them on insert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Public API keeps the foreign keys between the two services' tables: its users, policies, dynamics, households, datasets, regions and model catalog rows are named by 20 keys, which PostgreSQL checks on insert as the referenced table's owner. The models declare them under the chain's names, against tables modelled by their id alone in referenced_metadata, with the three indexes the Public API's deletes search. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Public API grants the simulation service's role UPDATE on five columns of simulation_runs, report_runs and household_jobs (status, modal_call_id, error_message, completed_at and updated_at), so it can record a Modal call's ID or fail a run Modal refuses, and nothing else. The privilege check now names each of those it lacks, asked with has_column_privilege by column number so a missing column counts as lacking its privilege. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…notes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ted keys Review round: the column privilege check had no test with a same-named table in another schema; the 20 expected keys move to a test module of their own, so other tests can expect the same names; the README no longer says the tests build the models' tables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 2: both tests repeated the same three statements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 3: a key name is unique only within its table, so a second key under an existing name on another table collapsed into one entry and the models' test passed. The expected keys are now keyed by table and name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ables Takes #898's merge of main and its models of the Public API's new head: 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. CI complete keeps the Public API schema job beside main's jobs, AGENTS.md describes both, and the rules document is re-rendered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One conflict, in simulation-deploy.reusable.yml's candidate check step: main no longer passes EXPECTED_LATEST there (publish_latest checks latest after moving it), and this branch adds REQUIRE_SIMULATION_DATABASE_ACCESS. The step takes main's environment plus REQUIRE_SIMULATION_DATABASE_ACCESS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main and this branch each added a rule, and the merge kept one side's
totals. The rules registry guard failed on the merged file ("rules.md is
stale") and passes on the rendered one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's one new commit is #898's merge, whose changes this branch already holds, so the tree is unchanged. The push starts this pull request's full CI now that its base is main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in main at e9a00b5, including the simulation service's database connection (#898), its schema comparison (#996) and the dataset download retry. Conflict resolved in docs/engineering/skills/database-access.md: the schema section takes main's list (the Stage 12 comparison tables, and the tables the simulation service writes, modelled in the simulation persistence library) and adds the run tables the Modal coordinator and workers write as the Modal worker role, with their separate Stage 12 mappings. The plan for #847's migration chain is gone, as on main. stage12_runtime/simulation.py merged without conflict: main's dataset download retry and this branch's run recording change different functions. The rules document is re-rendered: main's copy at e9a00b5 has stale totals (rendering its own registry gives 63 of 77 enforced mechanical rules and 64 of 78 blocking, against the 62 of 76 and 63 of 77 it shows), and this branch's registry is identical to main's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes #897
Same-Repository Draft PR Check
PolicyEngine/policyengine-sim-api, not a personal fork.Changelog
changelog.d/<name>.<type>.md, whose type is breaking, added, changed, fixed or removed:changelog.d/897.added.md.Summary
The Simulation Entrypoint (the simulation service) connects to the Postgres database that holds the Public API's v2 tables, with its own login role, which can create simulations, reports, their runs, budget window calculations and household jobs there, and update the call state of the runs it starts. This repository gets no schema and no migration chain of its own: the Public API's migration chain creates those tables, and its privilege manifest grants the role. This replaces this PR's earlier design, which gave the entrypoint its own
simulationschema, an Alembic chain and a migration job before each deploy; all of that is removed, with the rules that only served it.libs/policyengine-simulation-persistence):SELECTandINSERTon the tables it creates rows in,SELECTon the policy, dynamic, household and catalog tables a run reads, andUPDATEon five columns ofsimulation_runs,report_runsandhousehold_jobs(status,modal_call_id,error_message,completed_at,updated_at), so it can record a Modal call's ID or fail a run Modal refuses; and a check of which the connected role lacks, withhas_table_privilegeandhas_column_privilege;simulations,reports,simulation_runs,report_runs,budget_window_runsandhousehold_jobs, matching the Public API's tables column for column, with their constraints and indexes. A report run names the baseline and reform simulation runs it compares; a report's budget window is calculated as abudget_window_runsrow with one report run per year.23503. SQLAlchemy needs a definition of each referenced table to declare a key, soreferenced_metadatamodels each by itsidalone; nothing reads or writes through them.SIMULATION_SERVICE_DATABASE_URLas a pinned Secret Manager version on every deploy.GET /readyreportssimulation_database {reachable, has_required_privileges}without changing its status: the route is public, so it says only yes or no, and the service writes the reason to its own log (simulation_database_unreachablewith the error's type, orsimulation_database_missing_privilegeswith each missing privilege). A check that reached the database answers/readyfor 30 seconds, so repeated requests don't each take a database connection; a failed check isn't reused. The check runs on a thread of its own, in a transaction whosestatement_timeoutends any statement that runs past two seconds. The candidate check requires both answers to be yes, asking again while the check fails (as it does for a cold candidate), and points to the revision's Cloud Run logs when it fails; the stable and rollback checks don't require it.sim.persistence.missing-privileges-reported, with a proof, in a newpersistencecategory;sim.deploy.workflow-shell-safety,sim.deploy.container-context-minimalandsim.deploy.smoke-tests-strictare tightened, the first two with proofs.Simulation databasejob runs the persistence tests that need PostgreSQL onpostgres:17, and fails rather than skips without the server;CI completeneeds it. The tests that run as the service's role against the Public API's own tables need a database the Public API's chain built; they skip here, and the stacked pull request'sPublic API schemajob runs them.Environment variables
SIMULATION_SERVICE_DATABASE_URLpolicyengine-simulation-entry(the project named bySIMULATION_ENTRYPOINT_GCP_PROJECT_ID)SIMULATION_SERVICE_DATABASE_URL_SECRET_NAMEto its newest enabled version and mounts it withgcloud run deploy --set-secrets, on every deploySIMULATION_SERVICE_DATABASE_URL_SECRET_NAMEbeta,prod)env:of the "Deploy Cloud Run entrypoint candidate" step indeploy_entrypoint, whose job declaresenvironment: ${{ inputs.release_environment }}REQUIRE_SIMULATION_DATABASE_ACCESStrueenv:of the "Verify the complete deployed request path" stepSIMULATION_DATABASE_READY_MAX_ATTEMPTSSIMULATION_TEST_DATABASE_URL,SIMULATION_REQUIRE_DATABASE_TESTSenv:ofSimulation database, pointing at itspostgres:17servicePUBLIC_API_SCHEMA_DATABASE_URL,SIMULATION_REQUIRE_PUBLIC_API_SCHEMApublic_api_schemaChecked on GitHub on 2026-11-04, and the secret-name variable again on 2026-11-06 (read only):
SIMULATION_ENTRYPOINT_GCP_PROJECT_IDispolicyengine-simulation-entryandSIMULATION_ENTRYPOINT_RUNTIME_SERVICE_ACCOUNTissim-entry-beta-runtime@…/sim-entry-prod-runtime@…inbeta/prod. Checked on 2026-11-09 (read only, after the operator steps):SIMULATION_SERVICE_DATABASE_URL_SECRET_NAMEissimulation-service-staging-database-urlinbetaandsimulation-service-production-database-urlinprod; both secrets exist inpolicyengine-simulation-entrywith an enabled version, andsim-entry-beta-runtime@…/sim-entry-prod-runtime@…andsim-entry-gh-deployer@…hold Secret Accessor on them, as on thestage12-*-database-urlsecrets. The role exists in both v2 databases, and the Public API's seed workflow granted and verified its privileges. Not verified: that the deploy service account (the secretSIMULATION_ENTRYPOINT_GCP_DEPLOY_SERVICE_ACCOUNT) issim-entry-gh-deployer@…; thestage12-*-database-urlsecrets give it the same access.What this waits for before merging
policyengine_v2_simulation_serviceexists in each v2 database; its URL, which logs in through the pooler aspolicyengine_v2_simulation_service.<project ref>withsslmode=require, is insimulation-service-staging-database-urlandsimulation-service-production-database-url; the runtime and deploy service accounts can read each secret; and the secret-name variable is set inbetaandprod. After the first deploy,curl <candidate URL>/ready | jq .simulation_databaseshould show{"reachable": true, "has_required_privileges": true}(the candidate check requires it); if not, the revision's Cloud Run logs name the cause.main's branch rules require from someone other than the author.The pull request stacked on this one adds the CI check that compares the models with the Public API's tables.
Testing
Rebuilt on
mainat 04bf1fe on 2026-11-04 (this replaces the earlier history);mainmerged in at 906a64d on 2026-11-06, and again at 5a2b8e2 on 2026-11-09 (e9976b52): one conflict, in the "Verify the complete deployed request path" step ofsimulation-deploy.reusable.yml, wheremainno longer passesEXPECTED_LATEST(publish_latestcheckslatestafter moving it) and this pull request addsREQUIRE_SIMULATION_DATABASE_ACCESS; the step keeps both changes.3b9b6464re-renders the rules document, whose totals the merge left stale (the rules registry guard failed on it). After the merge, the 12 deployment tests intest_simulation_database_deployment.pypassed by node ID, and Ruff, the quality guards,uv lock --checkand Pyright for the persistence library and the entrypoint passed. A review round on the merge found no bugs. Every commit was written test first: the new or changed tests were run by node ID and seen to fail for the expected reason, then to pass. Before each commit, Ruff (the CI-pinned version), the quality guards with the rules registry, the generated rules document,uv lock --checkfor the persistence library and the entrypoint, and Pyright for both passed; after review, every commit was checked again for all but Pyright.public_api_schemaran aspolicyengine_v2_simulation_serviceagainst a database built with the Public API's v2 migration chain and privilege manifest at the head of Record each run of a v2 simulation and report, and key results by run policyengine-api#4162 (772ca6b14): the role lacks no table or column privilege it needs, and creates and reads back a row in each of the six tables, naming catalog, dataset and household rows that exist. The models also matched those tables exactly (columns, defaults, constraints with the 20 keys, indexes and enums).sqlalchemy>=2,<2.1, so its own lock resolves the minor version the entrypoint's image installs (2.0); a test keeps the two locks on the same minor version.098f408b.🤖 Generated with Claude Code