Skip to content

Create economy reports and simulations through POST /reports and POST /simulations - #1052

Draft
anth-volk wants to merge 42 commits into
mainfrom
v2plan/c1-9-economy-simulations-reports
Draft

anth-volk wants to merge 42 commits into
mainfrom
v2plan/c1-9-economy-simulations-reports

Conversation

@anth-volk

@anth-volk anth-volk commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1049

Summary

The Simulation Entrypoint creates economy reports and standalone economy simulations in the Public API's run tables, and starts their calculation on the v2 coordinator and workers by run ID. It replaces the draft #974, which was built on tables that no longer exist.

  • POST /reports (permission simulation:submit) takes the body the Public API sends: country, report_type: "economy", label, description, year, region_code, dataset_name (null for the region's default), each side's policy_id and dynamic_id, include_cliffs, and spm only when given. In one transaction it creates each side's new simulation and first simulation run, the report (options in inputs, the reform's policy in policy_id) and its first report run (initial, no budget window); every run is pending with no Modal call ID. It then starts the report run's coordinator by name with a ReportExecutionInput whose evaluation_id is the report run and each side's simulation_execution_id its simulation run, and a context whose report_run names the three runs. No Stage 12 evaluation record is passed. It answers 201 with report_id, report_run_id, the run's status and each side's simulation, run and whether it was reused.
  • POST /simulations creates a standalone economy simulation and its first run, and starts that run's simulation function by name with the run's SimulationExecutionInput and a context naming simulation_run_id. It answers 201 with simulation_id, simulation_run_id and the status.
  • Only the default version. Both routes run the v2 version manifest's default worker's .py version and name the country's catalog version for it; no request names a version or an ID.
  • Reuse. A report side names an existing run instead of a new one when that run is the newest of the same simulation (policy, dynamic, catalog version, dataset, region and year) that succeeded on the same country package and .py versions, its output file is under gs://<V2_SIMULATION_OUTPUT_BUCKET>/, a succeeded report run with the same options used it, and no report run refused to reuse it (an error message starting reused_simulation_). So both of a report run's simulation runs share its dataset and region, and a submission after a refused reuse calculates the simulation again. The two sides never name the same run. Without the bucket setting, nothing is reused.
  • Missing rows. Every row is read before writing: a missing catalog version, region, dataset (the region's default unless named), policy or dynamic is refused with 400 and its code before anything is written. A policy or dynamic setting parameters the catalog version lacks, or both setting one, answers 422 (missing_parameters, overlapping_parameters). A foreign-key violation (SQLSTATE 23503) on insert is answered with 400, naming the kind of row by its column.
  • Modal calls. The call's ID is recorded once Modal accepts the call. When Modal refuses it, or doesn't confirm it within ten seconds, the run is failed at once (with a report run's new simulation runs), message modal_call_refused: …, and the route answers 503 naming the record. Each update applies only while the run is pending with no call ID, so a run its coordinator already claimed is left alone and answered with 201.
  • Answers follow the Public API's client: 201; 400 with unknown_region, unknown_dataset or unknown_dynamic for the caller's own rows, and unknown_catalog_version, unknown_policy, no_economy_worker, dataset_not_in_bundle or missing_reference for the service's state; 422; 503 with Retry-After (not_configured, manifest_unavailable, database_unavailable, or modal_call_refused with report_id/simulation_id); 500 internal_error. No detail holds an exception's text. The OpenAPI document declares every answer, and the regenerated client reads each.
  • Status reads. GET /reports/{report_id} and GET /simulations/{simulation_id} (simulation:read) return the record with its newest run's status, versions, error message and times. Results stay the Public API's to read.
  • Staging check. Every staging deploy runs staging_economy_check when the household check runs: it creates a small report (current law against current law for country/wales in 2026) through the candidate, follows it and requires its run to succeed within 30 minutes. deployment_ready requires it, so a failure stops the production deploy.
  • Persistence. libs/policyengine-simulation-persistence gains read-only models of the catalog's regions and datasets columns, the dynamics table and parameter_values.dynamic_id; a dynamic reader beside the policy reader; the economy records, reuse search and reads; and EconomyRunStore, each step in a transaction of its own. Typed SQLAlchemy Core only; no DDL.
  • Refactor. The household routes' failure-log helpers moved unchanged to route_support, which both route modules use.

Builds on, and what merges first

Environment variables and secrets

Name Provider Wiring Verified
V2_SIMULATION_OUTPUT_BUCKET (new for the service) GitHub Actions environment variable V2_SIMULATION_OUTPUT_BUCKET in beta and prod deploy_entrypoint job (environment release_environment) → deploy step env: V2_SIMULATION_OUTPUT_BUCKET_VALUE → deploy-cloud-run-simulation-entry.sh validates it and writes it to --env-vars-file The variable exists in both environments; its value names the bucket C1 · 6 created (not read here).
SIMULATION_SERVICE_DATABASE_URL Secret Manager, name from the environment variable SIMULATION_SERVICE_DATABASE_URL_SECRET_NAME unchanged: --set-secrets Deployed; /ready passes. The role's grants cover every table and column written here (B1 · 1).
MODAL_TOKEN_ID, MODAL_TOKEN_SECRET Secret Manager, names from MODAL_TOKEN_ID_SECRET_NAME, MODAL_TOKEN_SECRET_SECRET_NAME unchanged since #1046: --set-secrets on every deploy Not verified here: that the token may call the coordinator and simulation functions.
SIMULATION_V2_MODAL_ENVIRONMENT, SIMULATION_V2_MANIFEST_NAME, APP_ENVIRONMENT workflow input and literal unchanged since #1046: env file Yes
Staging check: SIMULATION_ENTRYPOINT_AUTH_ISSUER, SIMULATION_ENTRYPOINT_AUTH_AUDIENCE, SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID, SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_SECRET GitHub Actions secrets of beta staging_economy_check job, environment: release_environment, step env: The same secrets the staging household check uses. Not verified here: the test client's permissions on POST /reports and GET /reports/{id}.

The coordinator and workers' own settings (policyengine-modal-worker-database, V2_SIMULATION_OUTPUT_BUCKET in the Stage 12 runtime secret) are unchanged.

Tests

Each change was a failing test first, committed with "(failing)" in its subject and seen failing for the expected reason (missing names, routes, jobs or scripts, or assertions), then the change that makes it pass. One commit's test passed before its change too, because the change only derives the caught errors from a type; its message says so.

Run by node ID, only the tests this PR adds or changes:

  • libs/policyengine-simulation-persistence: 41 test functions, 59 cases (plus the changed input-tables case in test_tables.py), in test_economy_run_records.py, test_economy_run_reuse.py, test_economy_run_store.py, test_economy_run_reads.py and test_dynamic_reform.py, against a PostgreSQL 17 database built by the Public API's v2 migration chain at master cd513dacc with its privileges applied and verified, acting as the simulation service's role.
  • projects/policyengine-simulation-entry: 92 test functions, 168 cases: the new test_economy_*.py and test_staging_economy_check.py files, and the changed cases of test_openapi.py (route table, the old gateway's contract, the new routes' answers), test_config.py and test_deployment_assets.py (the release environment count).
  • projects/policyengine-apis-integ: test_economy_generated_client.py, 8 cases, against the client regenerated from this branch with ./scripts/generate-clients.sh. Against a client generated from main it fails to import create_report.

Before each commit: CI's pinned Ruff (format and lint), the rules registry render, the quality guards (shellcheck, file length, locked installs), uv lock --check, and Pyright for both changed packages. No other tests were run here; CI runs the rest.

Review

Four read-only adversarial rounds (the review-code format):

  1. Two bugs: a report's 422 located reasons at policy_id rather than baseline.policy_id/reform.policy_id; a create whose commit failed after its inserts answered a 503 naming nothing, which the Public API reads as nothing created. Both fixed test-first, with the Public API's answer rules: every refusal's detail is written from the request, never from an error's text.
  2. One bug from those fixes: a request ID header over 255 characters made submissions answer 500, since a Modal call's context takes at most 255. Fixed: the call gets the request's ID only when it fits the observability runtime's rule (1 to 128 of [A-Za-z0-9._:-]), else a new UUID.
  3. No bugs. Its quality findings are fixed: tests at the request-ID limit, one source for the errors a report side wraps, and the staging check asking again after a 429 or any 5xx.
  4. The code those fixes added: no bugs. Its two wording notes are fixed.
  5. After merging Coordinate economy runs through the run tables instead of the evaluation records #1051 (the standalone context now set through Coordinate economy runs through the run tables instead of the evaluation records #1051's simulation_run_id field, and the deadline message): no bugs; the calls match the merged coordinator and workers exactly.
  6. Its two quality findings (the context takes its runs once, so the artifact prefix can't name another run; a test that the deadline message names the latest read): no bugs; its wording notes are fixed.

CI

The first run failed two jobs, fixed test-only in 31c4c7f8: test_tables' list of input tables lacked the three this PR models, and Pyright refused an optional field read in the generated-client test. A later run failed one executor test that #1051 had moved code under; #1051 fixed it, and 3cb8d1fb merges that fix. Every check passes on 3cb8d1fb.

Not done or not verified

  • The staging check's region country/wales must be in the staging catalog for the default worker's .py version; not verified.
  • UK local-area breakdowns are left out: C1 · 21 decides how they are stored.
  • Restarting a run whose call never started is C1 · 19.
  • Left as found by the reviews: reuse treats a simulation whose policy or dynamic was deleted (ON DELETE SET NULL) as current law, though the Public API deletes no policies; REFUSED_REUSE_PREFIX copies the coordinator's reused_simulation_ error codes with no test tying the two. The household path (Calculate stored households as household jobs through /household-calculations #1046) still answers a create whose commit failed after its insert with a 503; a separate change.

🤖 Generated with Claude Code

anth-volk and others added 30 commits November 12, 2026 19:09
…cy's (failing)

A simulation names a dynamic as well as a policy. Its parameter values are
read as reform values, with the policy reader's periods, and refused when the
running catalog version lacks a parameter they set. A parameter both set is
refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
read_dynamic_reform reads a dynamic's parameter values with the policy
reader's periods, and refuses one setting parameters the running catalog
version lacks (MissingPolicyParametersError, now naming its source).
combine_reforms joins a simulation's policy and dynamic values, refusing a
parameter both set (OverlappingReformError). The read-only models gain the
dynamics table and parameter_values.dynamic_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…un to reuse (failing)

The service reads the country's model, a region and a dataset of the
catalog version, and creates a report, its new simulations and runs, and
its first report run in one transaction, or a simulation and its first run;
a foreign-key refusal names the column. A report side may name a run that
already succeeded instead. It records a Modal call's ID, or fails the run
and its new simulation runs, only while the run is pending with no call ID.

A run is reused only when it is the newest succeeded run of the same
simulation on the same versions, its file is in the output bucket, a
succeeded report run with the same options used it, and no report run
refused to reuse it.

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

economy_run_records reads the country's model and a catalog version's
region and dataset, and creates in one transaction a report, its first run
and each side's new simulation and pending run, or a standalone simulation
and its first run. A report side may name a succeeded run instead
(find_reusable_run): the newest of the same simulation on the same
versions, with its file in the output bucket, used by a succeeded report run
with the same options, and never refused for reuse. A foreign-key refusal
is EconomyRecordReferenceError, naming the table and column. A Modal call's
ID is recorded, or the run failed (with the report run's new simulation
runs), only while the run is pending with no call ID.

The read-only models gain the catalog's regions and datasets columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n (failing)

The store reads a request's catalog version, model, region and dataset, each
side's policy and dynamic values, and each side's reusable run in one
transaction, never giving both sides the same run; it refuses each missing
row with its own error. It creates a report or a simulation, records a call
or fails an unstarted run, each in its own transaction.

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

EconomyRunStore reads a request's catalog version, model, region and
dataset (the region's default unless one is named), each side's combined
policy and dynamic values and, when asked, each side's reusable run, in one
transaction; the reform never reuses the baseline's run. A missing region
or dataset is UnknownRegionError or UnknownDatasetError. Creating a report
or simulation, recording a call and failing an unstarted run each take
their own transaction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A report run's coordinator takes the report run's ID, with no evaluation
record, claims that run under its Modal call ID and starts a worker per
simulation run it doesn't reuse; each worker claims its own simulation run
under its own call ID, and a simulation run can be calculated on its own.
The comparison path keeps its evaluation records.

The tests fail because the coordinator still requires an evaluation
record, the worker still mirrors its evaluation child row, the run store
lets any call take a running simulation run and has no release, and the
contract has no worker context naming a simulation run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…start (failing)

The service finds the default v2 worker's economy functions, refuses each
missing row with its code before writing, creates the records and runs,
then starts the run's call: the coordinator, given the report with its run
IDs and no evaluation record, or a standalone run's simulation function,
given the run's ID. It records the call's ID, or fails the run and answers
503 naming the record. Reports reuse a succeeded run only when the output
bucket is configured. The routes answer 201, 400, 422, 503 with
Retry-After, and a JSON 500, and the OpenAPI document declares each.

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

The Simulation Entrypoint's new routes find the v2 version manifest's
default worker's economy functions, read the request's catalog rows and
stored values (economy_submissions), create the records and runs in one
transaction, and start the run's Modal call by name: the report run's
coordinator with the report and its run IDs, and no Stage 12 evaluation
record, or a standalone simulation run's simulation function with its run's
ID (economy_calculation_inputs, economy_workers). They record the call's ID,
or fail the run, and its new simulation runs, and answer 503 naming the
record. A report side reuses a succeeded run when V2_SIMULATION_OUTPUT_BUCKET
names the output bucket.

The routes answer 201, 400 with the Public API's caller-row codes or the
service's own, 422, 503 with Retry-After and a JSON 500, never with an
exception's text. The household routes' failure log helpers move, unchanged,
to route_support, which both use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…atus (failing)

GET /reports/{report_id} and GET /simulations/{simulation_id} answer the
record with its newest run, by creation and then the larger ID: its status,
versions, error message and times, and a report run's simulation runs. An
unknown ID answers 404 (unknown_report, unknown_simulation), a database that
cannot be read 503, and the OpenAPI document declares each answer. The
staging economy check polls the report this way.

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

A detailed-save call now works on the Public API's run tables alone and
creates or claims no Stage 12 evaluation record:

- coordinate_report takes the report run named in its context, with no
  evaluation record. It claims the report run under its Modal call ID,
  checks the report input names that run and its two simulation runs,
  reuses each succeeded simulation run's stored output, and starts a
  worker for each other one with a context naming that simulation run.
- Each worker claims its own simulation run under its own Modal call ID,
  as the household functions claim their jobs, and completes or fails it
  only while its call holds it. A run that already succeeded returns its
  stored output's descriptor; one another call holds, or that failed, is
  not calculated.
- A simulation run can be calculated on its own: a worker given a
  simulation with no output plan plans it as a report comparing it with
  itself.
- A restarted coordinator stops each worker its pre-empted attempt
  started and releases that worker's run before starting a new one. A
  failed or cancelled coordinator fails its report run and the simulation
  runs it left unfinished, never a reused run.

The comparison path's calls keep their evaluation records, and the shared
planning, worker start, child wait and aggregation move into one core
module both coordinators use.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
economy_run_reads returns a report, its two simulations and its newest run
(created last, ties to the larger ID), or a simulation and its newest run,
with each run's status, versions, error message and times. The store reads
each in its own transaction. GET /reports/{report_id} and
GET /simulations/{simulation_id} (permission simulation:read) answer it,
404 for an unknown ID, 503 when the database cannot be read, and the JSON
500 otherwise. Results stay the Public API's to read.

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

Every deploy names V2_SIMULATION_OUTPUT_BUCKET, from the environment's
GitHub variable, and stops without a valid bucket name. Every staging deploy
creates a small report, current law against current law for Wales in 2026,
through the candidate's POST /reports, follows its run through
GET /reports/{report_id} and requires it to succeed; deployment_ready
requires the check, so a failure stops the production deploy.

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

The entrypoint's deploy passes V2_SIMULATION_OUTPUT_BUCKET, from the
environment's GitHub variable of that name, to Cloud Run on every deploy,
and stops without a valid bucket name. The new staging_economy_check job
creates a small report on every staging deploy, after the v2 default has
moved, through the candidate's POST /reports, follows its run through
GET /reports/{report_id} and requires it to succeed within 30 minutes
(simulation-staging-economy-check.sh). deployment_ready requires its result
(simulation-staging-economy-ready.sh), so a failure stops the production
deploy. The release environment count now includes the new job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The generated client sends the Public API's report and simulation bodies,
keeps omitted SPM settings omitted, and reads every answer the routes
declare: the created records, each error with its code and any record, the
422 with its code, and both reads. Run against a client generated from main,
the test fails to import create_report; against this branch's, it passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The entrypoint's README describes POST /reports, POST /simulations and
their reads, the reuse rule and every answer; DEPLOYMENT.md the output
bucket setting and the staging economy check; the persistence README the
economy records, the reuse search, the store and the dynamic reader. The
changelog fragment announces the routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lier worker (failing)

A run that changes just before its release must stop the worker the
coordinator already started for the other run. A worker an earlier attempt
started, whose cancellation didn't reach it, may claim its run before the
restarted coordinator's new worker does; the coordinator must then wait for
that worker instead of failing the report. The release test now checks
each earlier worker is cancelled just before its run is released, and the
uncancellable-worker test that the earlier call's completion is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rker holding its run

When a restarted coordinator can't release a simulation run because it
changed meanwhile, it now stops the workers it already started before
failing the report, as any other failed start does. When a worker it
starts finds its run held by an earlier attempt's worker that Modal never
cancelled, the coordinator waits for that worker's call, which records the
run, instead of failing the report and the other call's run. The child
wait asks each coordinator how to read a finished child's result.

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

The detailed-save worker's calculation seam is a Protocol with the
keyword-only signature it is called with. The precompute planner imports
the worker's runtime parameters from their own module, a worker's context
check no longer calls every run a comparison run, the persistence README
says a coordinator fails and releases its simulation runs, and the docs say
a standalone call's artifact prefix must contain the run's ID and that a
restarted coordinator waits for an uncancelled earlier worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t (failing)

- A report's refused policy or dynamic names its side: the store raises
  ReportSideError, and a report's 422 locates each reason at
  baseline.<field> or reform.<field>.
- A create whose commit can't be confirmed raises
  EconomyRecordOutcomeUnknownError, and the route answers 500, which tells
  the Public API the record may exist, never a 503 saying it doesn't.
- Every refusal's detail is built from the request, never from a store
  error's text.
- The Modal call's request ID is the request's own X-Request-ID.
- The staging check asks again after a read that got no answer, gives each
  request a time limit, and its job one longer than its deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e its release (failing)

A US national simulation run started on its own takes the segmented path,
so its segments must accept the standalone role. A run whose earlier
worker completes it just before a restarted coordinator releases it is
used as stored, and one it fails fails the report with its own cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r finished before its release

A simulation run started on its own is segmented as a report's baseline
or reform is, so a US national one no longer fails in the deployed worker.
When a restarted coordinator's release of a simulation run is refused
because the earlier worker finished it meanwhile, the coordinator uses its
stored output, or fails the report with the run's own recorded cause, or
waits for another call that now holds it, instead of failing with
simulation_run_changed. The pre-empted comparison-run test now checks its
children are left running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 503 after a possible write

- A report side's refused policy or dynamic is raised as ReportSideError,
  and a report's 422 locates each reason at baseline.<field> or
  reform.<field> rather than at a field the body doesn't have.
- A create whose commit fails after its rows were inserted raises
  EconomyRecordOutcomeUnknownError: the commit may have been applied. The
  route answers the JSON 500 (internal_error), which the Public API reads as
  a record that may exist, instead of a 503 saying nothing was created, and
  logs economy_record_outcome_unknown.
- Every refusal's detail is written from the request and the worker, never
  from an error's text, since the Public API passes 4xx details on to its
  callers.
- The Modal call's request_id is the request's X-Request-ID, not the
  observability ID, which travels in the observability context.
- The staging check gives each request 30 seconds, asks again after a read
  that got no answer, and its job stops after 40 minutes.
- Two tests of this branch follow the changes: a report read's policy and
  dynamic refusals now arrive with their side, and refusal details no longer
  hold the store's text. Docstrings now say a run claimed meanwhile is
  answered pending, and that a reused run may still be refused; the unused
  CALLER_ROW_REFUSAL_CODES is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed run (failing)

A simulation run that already failed fails its report run with
simulation_run_failed and the run's own recorded cause, whether the
coordinator finds it so while preparing or at its release. A run used as
stored at its release is never written when the report then fails. New
tests cover a run another call holds at its release, which is waited for,
and one left pending, which gets a new worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ced (failing)

A request whose X-Request-ID is longer than the call's context takes, or
holds characters outside the observability runtime's, gets a new UUID as
the call's request ID instead of failing with 500. An unknown commit is
logged once, with its error type, and the staging check's submission gets
60 seconds, its other requests 30.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eused at its release unwritten

A report run whose simulation run already failed records
simulation_run_failed with the run's own recorded failure, whether the
coordinator finds it so while preparing or at its release. A run the
coordinator reads as succeeded or failed at its release is kept in its
run states, so a later failure of the report writes nothing to it, and
reading its stored output is timed as any reused output's read. Docstrings,
the test helper for a cancelled earlier worker and the migration doc's
segmentation section now match the code.

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

The request middleware keeps the caller's X-Request-ID unchecked, and a
Modal call's context takes at most 255 characters, so a longer header made
every economy submission answer 500. The routes now pass the request's ID
only when it fits the observability runtime's rule (1 to 128 of
[A-Za-z0-9._:-]), and otherwise a new UUID.

Also from round 2's findings:
- An unknown commit is logged once, by the route, with its error type in
  the attributes the observability runtime keeps.
- The staging check's submission, which isn't asked again, gets 60 seconds,
  Cloud Run's own limit; its other requests keep 30.
- ReportSide and SideRefusal type a report side and its refusal, and the
  refusal helpers use them.
- Docstrings: the created answers' status, submit_report's errors, the
  module docstring's caller-row codes, the persistence README's new errors
  and the staging script's comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nshortened (failing)

A report run whose simulation run already failed records that run's own
failure as it is, as when a worker fails during the report, so its
correlation ID is never shortened away; a run that recorded none gives
simulation_run_failed. A run found failed is not written again, and the
test of a run reused at its release checks the report failed in its
aggregation.

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

A report run whose simulation run already failed, found so while
preparing or at its release, now records the failure that run recorded,
as when a worker fails during the report, so its correlation ID is never
shortened away; only a run that recorded none gives simulation_run_failed.
The run-state and reused-output docstrings now say exactly which reads
they cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 7 commits November 12, 2026 20:51
- The call keeps a request ID of 128 characters and replaces one of 129,
  on both POST routes, and a request whose state holds no usable ID gets a
  new one (call_request_id_for).
- Every error SideRefusal names is raised from a report read with its side.
  This passes before the change too: the change derives the caught errors
  from that type, so the two can't drift apart.
- The staging check asks again after a 429 or any 5xx Cloud Run answers in
  the service's place, not only a 503.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- call_request_id_for, public now, is tested at the 128-character limit on
  both POST routes and with no usable state value. Its docstrings, and the
  routes' module docstring, name the real header, X-PolicyEngine-Request-Id,
  say a replaced ID matches none of the request's log records (the
  observability ID still joins them), and that a failure before a handler
  runs gets the middleware's plain-text 500.
- The errors a report read wraps with their side are taken from SideRefusal
  itself, so the type and the caught errors can't name different errors.
- The staging check asks again after a 429 or any 5xx, which Cloud Run's
  front end may answer in the service's place, as it does after a 503.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mment and the docs

A report run repeats a 512-character recorded failure with its trailing
correlation ID. The migration doc and the simulation_run_failed comment
say that code is recorded only for a run that recorded no failure, and
the test invoker records the calls it restores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on' into v2plan/c1-9-economy-simulations-reports

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dline message (failing)

C1 · 8's contract now has Stage12InvocationContext.simulation_run_id: a
standalone run's context validates with it set, and a report run's with it
unset. These pass before the change too, which only builds the context
through the model instead of adding the key to its JSON. The staging
check's deadline failure says what the last read found: the run's status,
the HTTP status, or no answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t read at the deadline

- With C1 · 8 merged, a standalone simulation run's context sets
  Stage12InvocationContext.simulation_run_id through the model, so the
  contract validates it (a call names a report run or a simulation run, not
  both), instead of adding the key to the dumped JSON.
- The staging check's deadline failure ends with what the last read found:
  the run's status, the status a read answered, or no answer.
- The routes' module docstring says a failure outside a handler's try gets
  the middleware's plain-text 500 (review round 4).

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

The output planning both coordinators run moved into coordination_core,
so the comparison coordinator's stage-measurement test replaces the
planner there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 5 commits November 12, 2026 21:12
…fore its status

- test_tables' list of the read-only input tables gains dynamics, regions
  and datasets, which this branch models.
- The generated-client test asserts the report's run is present before
  reading its status, as Pyright requires for an optional field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A running run, then a front end answering 503 until a 2-second deadline:
the failure names the 503. It passes before and after; review round 5 found
nothing tested that a later read replaces an earlier one in the message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
economy_calculation_inputs._context takes one runs argument, a report run's
IDs or a standalone simulation run's ID, and builds both the context's
run fields and its artifact prefix from it, so a prefix naming another run
can't be built (review round 5). The existing prefix tests cover both calls,
before and after.

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

Review round 6's wording notes: the queued 503s are the service's own
answer, and _context's runs is a report run's IDs or a standalone
simulation run's ID.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on' into v2plan/c1-9-economy-simulations-reports

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

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.

Create economy simulations and reports through POST /simulations and POST /reports

1 participant