Skip to content

Serve economy reports at /v2/reports and save v2 report and simulation IDs - #4108

Draft
anth-volk wants to merge 20 commits into
v2plan/1g-126-countries-tablefrom
v2plan/2a-202-v2-economy-reports
Draft

anth-volk wants to merge 20 commits into
v2plan/1g-126-countries-tablefrom
v2plan/2a-202-v2-economy-reports

Conversation

@anth-volk

Copy link
Copy Markdown
Collaborator

Fixes #4107

Summary

  • Adds /v2/reports for economy (society-wide) reports. The simulation service creates, runs and stores them; the Public API calls its economy report routes (Run and store v2 economy reports in the Simulation Entrypoint policyengine-sim-api#974) through the generated policyengine-api-simulation-client.
    • POST /v2/reports?country_id= creates the baseline and reform simulations (the region by its catalog code), the report and its first run (trigger initial) on the country's current catalog version: the one published for the PolicyEngine.py version the Public API runs, which its v2 metadata routes also serve by default.
    • GET /v2/reports/{report_id} and GET /v2/reports/by-legacy-number/{number} read a report with its newest run and, once that run succeeded, its result in today's economy result format. A report with no run, such as one migrated from v1, is calculated the first time it is read (trigger system); the simulation service records one run when several first reads arrive together.
    • GET /v2/reports?country_id=&report_id=… lists 1 to 25 chosen reports, in the order asked, without their results, and starts no runs. The simulation service has no list route, so each listed report is one read.
    • POST /v2/reports/{report_id}/runs runs a report again on the current catalog version (trigger manual), reusing the simulation runs that exist for it unless {"force": true}. GET /v2/reports/{report_id}/runs lists the runs, newest first, with the catalog version and PolicyEngine.py version each used.
    • A country whose countries row does not allow society-wide calculations (a household-only country) gets a 422 naming it on submit and re-run, before anything is sent to the simulation service, even when the simulation client or its settings are unusable.
    • Every route is scoped to the country_id query; another country's report is a 404. No user credential is needed: a user keeps a report through /v2/user-reports.
  • Saved records accept v2 IDs. /v2/user-reports takes a v2 report_id and /v2/user-simulations a v2 simulation_id, alone or with the v1 ID, on create and on PATCH. The tables keep no foreign key to the simulation service's records, so each v2 ID is checked with the simulation service first, outside the v2 transaction. A saved record holding both IDs must name the v2 record migrated from that v1 record (400 otherwise); a PATCH that repoints a saved record locks its row and is refused (409) if it changed during the check. A shared report (/v2/shared-reports/{id}) now shows its report_id.
  • The generated client is imported only by libs/simulation_economy_reports.py, which implements a small protocol (services/v2/simulation_entrypoint/types.py) that everything else uses; services and routes are tested against a fake of it. The adapter reads the simulation service's declared answers: 404 (no such record; a 404 whose detail is exactly "Not Found" is FastAPI's answer for a route the service does not serve, which means the service cannot be used), 422 (its reason), 500 on a report read (its stored result cannot be read back, answered 502) and 503. Network failures, a gateway token that cannot be fetched and undeclared answers mean the service is unavailable (503).
  • The base URL, request identifier and gateway auth setup used by the existing Simulation Entrypoint client now come from shared side-effect-free helpers, which both clients use.

Migration work, as the PR skill asks:

  • Route groups and workflows: the report route group gains the reports segment; the contract registry gains economy_reports_v2, and user_report_associations_v2 and user_simulation_associations_v2 gain the v2 ID fields. Generated migration contract documents are refreshed.
  • What stays on the v1 path: every v1 route, including /{country}/economy/…, /{country}/report and /{country}/simulation, is unchanged. Nothing calls /v2/reports yet.
  • Newly prepared: economy reports on v2, calculated and stored by the simulation service; v2 IDs in saved reports and simulations.
  • User-visible contract changes: the new /v2/reports routes; report_id and simulation_id accepted by the saved-record routes, with their new 400, 409, 502 and 503 answers; report_id in the shared report.
  • Schema: no v2 Alembic revision. The report_id and simulation_id columns already exist.

Waits for before merging

Environment variables

No new variables. The new code reads:

  • SIMULATION_ENTRYPOINT_URL: GitHub environment secret (staging, production) → job env: of the Cloud Run deploy jobs in push.yml → deploy_cloud_run_candidate.sh, which adds it to --set-env-vars when it is not empty → Cloud Run runtime. The economy report routes exist only on the Cloud Run Simulation Entrypoint, so they use this URL whatever SIM_ENTRYPOINT selects (it is cloud_run_simulation_entrypoint in both environments).
  • GATEWAY_AUTH_ISSUER, GATEWAY_AUTH_AUDIENCE, GATEWAY_AUTH_CLIENT_ID, GATEWAY_AUTH_CLIENT_SECRET_RESOURCE: organization secrets → the deploy step's env: → the deploy script, which also sets GATEWAY_AUTH_REQUIRED=1 → Cloud Run runtime. The client secret is read at runtime from Secret Manager by the Cloud Run runtime service account. These are the values the existing Simulation Entrypoint client already uses.
  • The v2 runtime database settings, unchanged, to read the countries row (SELECT already declared for the runtime role).

Not verified: that the Auth0 client the gateway token comes from holds simulation:submit and simulation:read for the simulation service's audience (the existing calls to /simulate/economy/comparison and /jobs/{id} need the same two permissions), and that each environment's SIMULATION_ENTRYPOINT_URL serves a simulation service revision with the economy report routes.

The integration workflow change adds two test modules to existing steps; their database URLs are the job's literal disposable values.

Inline suppressions

None added.

Testing

  • Unit: the economy report service and routes against SQLite (including a test-only countries row with only household_enabled set) and a fake simulation service; the saved-record reference checks; the adapter against a client generated locally from the simulation service's current OpenAPI document, through an in-memory transport (these skip when the client is not installed). pytest tests/unit tests/contract passes.
  • Integration, against a migrated disposable Postgres database as the runtime role: the household-only refusal on submit and re-run with no call to the simulation service; a society-wide submission through the route; a saved report and a saved simulation changed during their reference check are not repointed.
  • ruff format --check ., ruff check ., mypy, uv lock --check, scripts/run_quality_guards.py, scripts/export_migration_contracts.py.

Notes for reviewers

  • Saving a report with both IDs reads the whole report from the simulation service, because only that read returns the v1 report it was migrated from. If the service cannot read back the report's stored result, that save answers 502 until a newer run succeeds. A lighter report read in the simulation service would remove this.
  • force: true needs no credential and has no rate limit, like the v1 economy routes.

🤖 Generated with Claude Code

anth-volk and others added 20 commits October 27, 2026 20:43
…nerated client

Economy reports live in the Simulation Entrypoint: it creates their
simulations, reports and runs, and stores their results. The Public API
reaches them through `EconomyReportSimulation`, a small protocol in the new
`services/v2/economy_reports/types.py`: submit a report, read one by ID or by
v1 report number, run one again, list its runs, and read a simulation. Its
records are plain dataclasses, and its three failures say what happened: the
record does not exist (404), the entrypoint cannot run the request (422, with
its reason), or the entrypoint cannot be used now.

`libs/simulation_economy_reports.py` implements the protocol with the client
generated from the entrypoint's OpenAPI document
(`policyengine-api-simulation-client`), and is the only module that imports
it. Requests go to `SIMULATION_ENTRYPOINT_URL` with the existing gateway
bearer token and the request's correlation identifiers. The generated client
parses only the success answers the entrypoint's OpenAPI document describes,
and raises on the entrypoint's own refusals, whose `detail` is text: a
response hook turns every error answer except a 401 (which the gateway auth
retries with a new token) into the protocol's errors before the generated
parser runs. Network failures, other answers and answers that do not parse
mean the entrypoint is unavailable.

The client is not a dependency yet: no release contains the economy report
routes. Nothing imports the adapter at module level (it joins the banned
module-level imports), so the application starts without the client.

Tests (failed first: the adapter module did not exist): against the locally
generated client and an in-memory transport answering as the entrypoint
does, the request bodies and paths of all six routes, a succeeded report
read back with its exact result (a real congressional district result as the
entrypoint serializes it), a migrated report with no run, each error answer,
a network failure, a token refreshed after a 401, the request ID header, and
the URL and gateway auth read from the environment. They skip when the
client is not installed.

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

`POST /v2/reports?country_id=us` takes an economy report: its year, a region
code of the current catalog version (`state/ca`, `country/wales`), an
optional dataset name, and the baseline and reform, each a v2 policy and
dynamic (an empty object means current law). The simulation service creates
the two simulations, the report and its first run, starts the run, and the
route answers 201 with the report and that run. The run uses the current
catalog version: the one published for the PolicyEngine.py version the
Public API runs, which its v2 metadata routes also serve by default.

Before the simulation service is asked, the country's `countries` row must
allow society-wide calculations; a household-only country (or one with no
row) gets a 422 naming it, and nothing is created or sent. The simulation
service's own refusals (no catalog for the version, no such region or
dataset, a region below the country without a household filter, an unknown
policy or dynamic) are 422s with its reason. An unusable simulation service
or v2 database is a 503, logged with its reason; anything else is a
secret-safe 500. The body must name the query's country (400), has no
options yet and is limited to 16 KiB. No user credential is needed: a user
keeps a report through `/v2/user-reports`.

The route follows the v2 layout: a `reports` route package calling one
method of a new economy report service, which reads the countries row
through its own read connector (the runtime role already holds SELECT on
`countries`). The default service uses the generated-client adapter and the
v2 database, both loaded only when a request needs them; without the client
installed the route answers 503. Requests are logged under the `report`
route group, the contract registry gains the `economy_reports_v2` workflow,
and its generated documents are refreshed.

Tests (failed first: the service and route tests could not import the
service's session module and the default adapter factory): the service
against SQLite with a test-only household-only country row (`zz`, only
`household_enabled` set) and a country with no row, both refused before the
fake simulation service is called; the submission sent on the current
version; and, through the routes on the real service, the 201 body, the
country mismatch, eleven invalid bodies and four missing fields, the
household-only 422 naming the country, the relayed refusal, the 503s, the
500, the missing client and the OpenAPI document.

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

`GET /v2/reports/{report_id}?country_id=us` returns the report, its newest
run and, once that run succeeded, its result in today's economy result
format. `GET /v2/reports/by-legacy-number/{number}?country_id=us` finds the
report migrated from a v1 report output number and answers the same way. A
report of another country, or one the simulation service does not have, is
a 404 naming the report and country.

A report with no run, such as one migrated from v1, is calculated the first
time it is read: the read starts a run with the `system` trigger on the
current catalog version, without forcing new simulation runs, then reads the
report again so the answer shows the new run as the simulation service
stored it. The run is not started when the country's `countries` row no
longer allows society-wide calculations, and a run the simulation service
refuses (for example, a region the current catalog version lacks) is logged
and leaves the report not run, so the read still answers.

Tests (failed first: the service could not import the not-found error, and
the routes answered with the catch-all 404 and 405): the service reading a
report as stored, unknown and other-country reports, the first run started
with the system trigger and current version then read again, no run for a
report that has one, none for a report of the test-only household-only
country, a refused first run, and the v1-number lookup found and not found;
and through the routes, the succeeded result, the first run, the 404s, seven
reads outside the contract, and the OpenAPI document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`POST /v2/reports/{report_id}/runs?country_id=us` runs the report again on
the country's current catalog version with the `manual` trigger. The body
is `{}` to reuse the simulation runs that already exist for that version and
year (most often the current-law baseline), or `{"force": true}` to
calculate both simulations again as new attempts; `force` takes only a JSON
boolean. It answers 201 with the new run. As on submit, a country whose
`countries` row does not allow society-wide calculations gets a 422 naming
it before the simulation service is asked; otherwise the report must be the
country's (404), and the simulation service's refusals are 422s with its
reason.

`GET /v2/reports/{report_id}/runs?country_id=us` lists every run of the
report, newest first, each with its trigger, status, simulation runs,
comparison step, and the catalog version (`tax_benefit_model_version_id`)
and PolicyEngine.py version it ran on.

The route builder's operations become module-level response builders called
from each route, which keeps the builder within the complexity limit.

Tests (failed first: the service had no re-run or run list, and the routes
answered with the catch-all 405): the re-run's version, trigger and force
flag, the household-only refusal with no call to the simulation service,
unknown and other-country reports, a relayed refusal, and the runs listed
newest first; and through the routes, the 201 body, reuse unless forced, the
household-only 422, four bodies outside the contract, the run list, an
unknown report and the OpenAPI document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`GET /v2/reports?country_id=us&report_id=<id>&report_id=<id>` lists the
country's reports among the given IDs (one to 25, the repeated `report_id`
key), in the order asked and once each: each report's simulations, status
and newest run, without its result, so a page of saved reports can show
them without loading every result. Unknown reports and other countries' are
left out. Listing does not start the first run of a report that has none;
reading the report does. Each listed report is one read from the simulation
service, which has no list route, hence the limit of 25.

The report item splits into `ReportSummaryItem`, which the list returns, and
`ReportItem`, which adds `result` for single reads and submissions.

Tests (failed first: the service had no listing, and the route answered with
the catch-all 405): the order kept, duplicates, unknown and other-country
reports dropped, and no run started; and through the route, summaries
without results, five queries outside the contract (no IDs, a bad ID, no
country, two countries, 26 IDs), and the OpenAPI array parameter.

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

`POST /v2/user-reports` now takes a v2 `report_id`, a v1
`legacy_report_id`, or both (at least one, as saved policies already do),
and `PATCH /v2/user-reports/{id}` can repoint a saved report to a v2
`report_id` (not clear it). The v2 tables keep no foreign key to the
simulation service's reports, so before a `report_id` is saved the service
is asked for that report, outside the v2 transaction, as the v1 reference
already is: an unknown report, or one of another country, is a 422 naming
it, and an unusable simulation service is a logged 503 with nothing saved.
A shared report (`/v2/shared-reports/{id}`) now shows its `report_id`.

A new `simulation_refs` service holds the checks (`require_report`, and
`require_simulation` for saved simulations next), behind the
`SimulationReferenceChecker` protocol the association services take. The
default builds the simulation client only when a request checks a v2
reference, so the other saved-record routes do not depend on it, and its
module joins the banned module-level imports. The contract registry records
`report_id` as a patch field and a shared-report field.

Tests (failed first: the reference package did not exist, and the shared
read and route tests could not build their records): against SQLite and the
fake simulation service, saving by v2 ID, unknown and other-country reports
refused with nothing saved, both references checked, repointing, the
unavailable service, a service built without the checker, the required
reference, and the shared report's `report_id`; and through the routes,
create by v2 ID or both, neither refused, repoint, null refused, and the 422
and 503 mappings.

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

`POST /v2/user-simulations` now takes a v2 `simulation_id`, a v1
`legacy_simulation_id`, or both (at least one), and
`PATCH /v2/user-simulations/{id}` can repoint a saved simulation to a v2
`simulation_id` (not clear it). Before a `simulation_id` is saved, the
simulation service is asked for that simulation, outside the v2
transaction: an unknown simulation, or one of another country, is a 422
naming it, and an unusable simulation service is a logged 503 with nothing
saved. The check is the reference service's `require_simulation`, and the
contract registry records `simulation_id` as a patch field.

Tests (failed first: a v2 `simulation_id` was refused with 422 and the
service had no check): against SQLite and the fake simulation service,
saving by v2 ID, unknown and other-country simulations refused with nothing
saved, repointing, and the required reference; and through the routes,
create by v2 ID, repoint, null and missing references refused.

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

A new integration test migrates the disposable Postgres database, adds a
test-only countries row (`zz`, with only `household_enabled` set; no real
country is household-only yet) as the owner, and runs the economy report
service as the Public API's runtime role: submitting or re-running a report
for that country is refused with the message naming it, and the fake
simulation service is never called. Through the route, a society-wide
country's submission reads its row as the runtime role and reaches the
simulation service. The test-only row is removed afterwards, so later tests
see only the listed countries.

The cross-database integration workflow runs the new test in the step with
the countries list, and the workflow test checks that it does. The step's
environment is unchanged: the job's literal disposable database URLs.

Tests (failed first: the workflow test did not find the new test in the
workflow): locally against a disposable postgres:17 container, migrated and
granted as the workflow does, the two integration tests, alongside the
countries integration tests in one session.

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

The gateway bearer auth fetches its token inside httpx's auth flow, and
raises `GatewayAuthError` when Auth0 cannot be reached or refuses, or the
client secret cannot be read from Secret Manager. The economy report
adapter converted only httpx and parsing failures, so that error escaped:
every `/v2/reports` route, and every save of a v2 report or simulation ID,
answered a 500 instead of the 503 the protocol promises when the simulation
service cannot be used. The adapter now converts it to
`SimulationServiceUnavailableError`, with the reason, before any request is
sent.

Tests (failed first: `GatewayAuthError` escaped the adapter): an auth that
cannot fetch a token makes a read unavailable and sends nothing.

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

The protocol for the Simulation Entrypoint's economy report routes, the
records it answers, its two run enums, the submission and simulation-choice
inputs, and its three errors move from `services/v2/economy_reports/types.py`
to a new `services/v2/simulation_entrypoint/types.py`. The economy report
routes and service use them, and so do the checks of saved v2 report and
simulation IDs, the saved-record error mapping and the generated-client
adapter, none of which now depends on the economy report package. That
package keeps only its request types. Docstrings that said "API v1" now say
"the Public API".

Tests (failed first: four modules imported the economy report package): no
saved-record, reference-check, error-mapping or adapter module imports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion client cannot be built

The default economy report service built the simulation service adapter
before it read the country's `countries` row, so when the generated client
was missing or `SIMULATION_ENTRYPOINT_URL` or the gateway auth was unusable,
a household-only country's submission or re-run answered 503 instead of the
422 that names the country.

Both default factories now pass a `DeferredEconomyReportSimulation`, which
builds the adapter at its first call to the simulation service (and tries
again if that build fails). The economy report service therefore reads the
`countries` row and refuses a household-only country without the client or
its settings; a society-wide country still gets the 503 when the adapter
cannot be built. The saved-record reference checks use the same deferred
adapter, which replaces their own deferred wrapper. The new module joins the
banned module-level imports, like the other services the default factories
load.

Tests (failed first: building the default service raised the client's
unavailability before the countries check): with the client import failing,
the default service refuses the test-only household-only country on submit
and re-run and reports the simulation service unavailable for the US; and
through the route with the default factory, a household-only submission is a
422 naming the country.

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

The economy report adapter repeated three pieces of the Simulation
Entrypoint client of the v1 routes, and imported one of its private helpers,
because that module builds a client when it is imported. They now live where
both import them without side effects:

- `libs/simulation_http.py`: `required_base_url`, which reads a base URL from
  the environment and refuses one that is missing or not an absolute HTTP(S)
  URL free of credentials, a query and a fragment; and
  `attach_request_identifiers`, the request hook that sends the API
  request's correlation identifiers.
- `gateway_auth.gateway_bearer_auth_from_environment`, which returns the
  gateway bearer auth, None where auth may be left unset, or raises
  `GatewayAuthError` when it is partly set or required but missing.

The v1 client keeps its behavior and messages, including its warning when it
runs without auth; the adapter converts the two errors to the simulation
service being unavailable, as before.

Tests (failed first: the shared module and function did not exist): the
base URL read, missing and five unusable forms; the identifiers sent inside
and outside an API request; and the gateway auth selected when complete,
absent where not required, and refused when required or partly set. The
existing v1 client, service and route tests pass unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A saved report holding both a `report_id` and a `legacy_report_id` records
that the v2 report was migrated from that v1 report output, but each
reference was only checked to exist, so a caller could pair two unrelated
reports. Now, when a saved report would hold both, the simulation service's
report must record that it was migrated from that v1 report output;
otherwise the request is refused with the existing "references contradict
each other" error (400) and nothing is saved.

On create, both supplied references are checked together. On `PATCH`, a new
reference is checked together with the one the saved report keeps: the
service reads the saved report's references first, checks them outside the
v2 transaction as before, then refuses the update with a 409 if the saved
report changed meanwhile. A `PATCH` that supplies neither reference asks the
simulation service nothing.

The documentation now describes v2 IDs as accepted: the user-login
migration document's shared-report item gains `report_id`, its deferred item
becomes a section on v2 report and simulation IDs, and the saved report and
saved simulation model docstrings, the create fields and the route
description say what each reference is and how they pair.

Tests (failed first: an unrelated pair was saved, and a changed report was
repointed): against SQLite and the fake simulation service, a v2 report
migrated from another v1 report and one never migrated are both refused on
create; on patch, a new v2 or v1 reference that does not match the stored
one is refused and a matching one pairs them; a rename asks nothing; and a
saved report changed during the check is not repointed. The routes map the
two refusals to 400 and 409.

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

The simulation service's OpenAPI document now declares its error answers,
so the client generated from it parses them: 404 and 503 as
`EconomyErrorResponse` (`{"detail": "<text>"}`), 422 as
`EconomyRefusalResponse` (its reason as text, or FastAPI's refused fields),
and, on the two report reads, 500 when a succeeded run's stored result
cannot be read back. The adapter now turns each parsed answer into the
protocol's error, and the response hook that read error answers before the
old client's parser is removed:

- 422: the request is refused, with the service's reason or each refused
  field as `location: message`;
- 404: the record does not exist, except a 404 whose detail is exactly
  "Not Found", which is FastAPI's answer for a route the service does not
  serve (such as a revision without these routes) and so means the service
  cannot be used; the service's own 404s name the missing record;
- 500 on a report read: a new `SimulationRecordUnreadableError`, answered
  502 by the report routes and by saving or repointing a v2 report, which
  declare it in OpenAPI;
- 503, undeclared answers, answers that do not parse, network failures and
  a gateway token that cannot be fetched: the service cannot be used (503).

Tests (failed first: the new error did not exist, then a "Not Found" route
answer was read as a missing record and the declared 500 as unavailability,
then the routes answered 500 for the unreadable report and the OpenAPI
document lacked the 502): against the client generated from the service's
current document, every declared 404 on the five routes that have one, the
unserved-route 404 on reads and submit, the declared 500 on both report
reads, and the earlier refusal, unavailability, token and request-ID cases;
and through the routes, the 502 answers and their OpenAPI declarations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two reads of a report that has never run, such as one migrated from v1, can
arrive together; both see no run and both ask the simulation service for a
first run. The service now answers a `system` run of a report that already
has one with that newest run, under a lock, so it records a single run. The
Public API needs no change for this, because it starts first runs only with
the `system` trigger and no force, and answers with the report as the
service then holds it. A new test pins that: two simultaneous first reads
start one run between them, and both answer with it.

The test's fake simulation service follows the service's new rule for
`system` runs.

Tests (failed first: with a fake that adds a run for every request, as the
service did before, the two reads recorded two runs): two threads read the
same never-run report, each waiting until both have read it, and the fake
records one pending run, which both answers show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… report can run again

Re-running a report, listing its runs and saving its v2 ID first checked
that the report is the country's by reading the whole report from the
simulation service, which rebuilds its stored result. When that result
cannot be read back, the service answers 500, so the Public API answered 502
to the re-run that would make the report readable again, to listing its
runs, and to saving it, though none of them uses the result. Listing chosen
reports also answered 502 for the whole request when one of them was
unreadable.

The country now comes from the report's simulations: its runs name them, and
a simulation's country is the report's. A report with no run has no stored
result, so it is read whole as before. `read_report_country`, beside the
deferred adapter, does this for the report service and the saved-report
check; the check still reads the whole report when it must compare the v1
report the report was migrated from. Listing leaves out a report whose
result cannot be read back, as it leaves out unknown ones, and logs it; the
caller can tell from the IDs it asked for. These checks also no longer make
the service rebuild results nobody reads. The shared module is imported at
module level and leaves the banned module-level imports.

Tests (failed first: re-running, listing the runs of, and saving an
unreadable report each reached the full report read, and a listing including
one failed): with the fake simulation service, which now gives each report
its own simulations and runs, and can fail to read a report's result; and
the re-run of a report of another country that has runs is not found.

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

A saved simulation holding both a `simulation_id` and a
`legacy_simulation_id` records that the v2 simulation was migrated from that
v1 simulation, as a saved report holding both IDs does, but only saved
reports checked the pair. A saved simulation now gets the same rule: when it
would hold both, the simulation service's simulation must record that it was
migrated from that v1 simulation, or the request is refused (400) and
nothing is saved. A `PATCH` setting one reference is checked together with
the one the saved simulation keeps, and is refused (409) if the saved
simulation changes during that check; a `PATCH` setting neither asks the
service nothing.

The two services now share the pieces: `RecordReferences` (a saved record's
v2 and v1 IDs), `repointed_references` and `require_unchanged_references` in
the associations package, and a `require_migrated_from` that names the kind
of record. The reference service's `require_simulation` takes the v1 ID as
`require_report` does. The saved-simulation docstrings, route description,
migration document and changelog describe the rule.

Tests (failed first: an unrelated pair was saved on create and on patch, and
a simulation changed during the check was repointed): a v2 simulation
migrated from another v1 simulation and one never migrated are both refused
on create; on patch, a new reference that does not match the stored one is
refused, a matching one pairs them, and a rename asks nothing; and a
simulation changed during the check is not repointed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Repointing a saved report or simulation checks its references with the
simulation service before the update's transaction opens, then refuses the
update if the stored references changed meanwhile. That comparison read the
row without locking it, so two updates of one saved record could both pass
it before either committed: under Postgres READ COMMITTED the second waited
for the first's row lock and then wrote over it, leaving a v2 and v1 pair
that was never checked together.

An update that repoints a saved record now reads the row FOR UPDATE, so
updates of one saved record take turns and each compares against the row as
the one before it left it; the later one is refused with the 409 the check
already gives. A rename still takes no lock. The runtime role already holds
UPDATE on both tables, which a locking read needs.

The cross-database integration workflow runs the new test with the
signed-in identity tests, and the workflow test checks that it does; the
step's environment is unchanged.

Tests (failed first, against migrated Postgres as the runtime role: while
another connection held the row and changed its v1 reference, the update
did not raise and wrote the unchecked pair): for a saved report and a saved
simulation, the update waits for the other connection's lock, sees the
changed row, is refused with a conflict, and leaves the other change in
place.

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

Every `/v2/reports` route declared the same eight error answers, including
ones it cannot give: a 400 and a 413 on reads, a 404 on submit and listing,
a 502 on routes that never read a stored result. Each route now declares the
three every route can give (422, 500, 503) and its own: submit 400 and 413;
the two reads 404 and 502; a re-run 404 and 413; the run list 404.

A report route's unexpected failure was logged by its error type only. It is
now logged, as the saved-record routes' failures already are, with the
locations of its innermost frames, never its message. The shared
`failure_log_attributes` in the v2 errors module builds those attributes for
both. The simulation-service contract's module docstring now counts its four
errors.

Tests (failed first: every route declared the same eight answers, and the
logged failure had no frame locations): the exact declared answers of each
of the six operations, and an unexpected failure's log record naming where
it was raised without its message. The test re-enables the routes' logger,
because Alembic's logging configuration in earlier tests disables loggers
that already exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r failures as errors

Every report route described its 422 as a refusal of the report itself (a
country without economy reports, or a report the simulation service cannot
run), but only submit and re-run can refuse that way; the reads, the list
and the run list answer 422 only for a request outside the schema. Those two
routes now declare the longer description and the others the plain one.

A report route's unexpected failure, or the simulation service being
unavailable, was logged as a warning while the saved-record routes log the
same kind of failure as an error, so a filter on error severity missed
report-route 500s. They are now logged as errors.

The route-facing protocols of saved reports and simulations still described
saving only v1 records; their docstrings now say what each reference is, how
the two pair, and when an update is refused as a conflict.

Tests (failed first: four routes described the report refusal, and the
failure was logged as a warning): each route's 422 description, and the
logged failure's severity.

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

codecov Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.50575% with 187 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.38%. Comparing base (975c80f) to head (e9cfc7e).

Files with missing lines Patch % Lines
...olicyengine_api/libs/simulation_economy_reports.py 4.54% 105 Missing ⚠️
policyengine_api/fastapi_routes/dependencies.py 64.10% 13 Missing and 1 partial ⚠️
..._api/services/v2/simulation_entrypoint/services.py 63.33% 9 Missing and 2 partials ⚠️
...engine_api/services/v2/economy_reports/services.py 88.88% 7 Missing and 2 partials ⚠️
...ine_api/services/v2/simulation_entrypoint/types.py 91.81% 8 Missing and 1 partial ⚠️
...licyengine_api/fastapi_routes/v2/reports/routes.py 92.77% 3 Missing and 3 partials ⚠️
...engine_api/services/v2/simulation_refs/services.py 83.33% 4 Missing and 2 partials ⚠️
...pi/services/v2/economy_reports/database_session.py 71.42% 3 Missing and 1 partial ⚠️
...api/services/v2/economy_reports/transformations.py 73.33% 3 Missing and 1 partial ⚠️
policyengine_api/services/v2/associations/types.py 72.72% 3 Missing ⚠️
... and 9 more
Additional details and impacted files
@@                        Coverage Diff                        @@
##           v2plan/1g-126-countries-table    #4108      +/-   ##
=================================================================
- Coverage                          87.80%   87.38%   -0.43%     
=================================================================
  Files                                270      286      +16     
  Lines                              16333    17144     +811     
  Branches                            2653     2760     +107     
=================================================================
+ Hits                               14341    14981     +640     
- Misses                              1306     1461     +155     
- Partials                             686      702      +16     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

This branch was successfully deployed

1 active deployment
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.

1 participant