Skip to content

Record each user's report runs in /v2/reports, read reports by run, and take names from saved reports - #4313

Draft
anth-volk wants to merge 33 commits into
v2plan/c1-25-shared-report-keysfrom
v2plan/c1-28-user-report-runs
Draft

anth-volk wants to merge 33 commits into
v2plan/c1-25-shared-report-keysfrom
v2plan/c1-28-user-report-runs

Conversation

@anth-volk

Copy link
Copy Markdown
Collaborator

Fixes #4312

Summary

Records each user's report runs in /v2/reports, reads a report by run and as each user sees it, and takes names only from saved reports. Stacked on #4303 (its user_report_run_associations table and per-user column changes); retargets to master once #4303 merges.

Who asks. POST /v2/reports, POST /v2/reports/{report_id}/runs, GET /v2/reports/{report_id} and GET /v2/reports take an optional user credential: a bearer access token, the PolicyEngine-Unauthenticated-User header, or a server forwarding either (NativeRouteDependencies.optional_user). Without one the request acts as nobody; a credential that is sent but not valid is refused as on the user routes (401; 403 for a forwarded machine-client token). The OpenAPI document marks the credential optional.

Records (new services/v2/report_run_records):

  • A create or re-run with a credential checks the requester before the simulation service is asked (a browser credential naming a signed-in user answers 401), then, once the service has answered, records in one transaction a user_report_run_associations row for the run the answer names, created or joined: trigger initial or manual, requested_at when the Public API received the request, ON CONFLICT (user_id, report_run_id) DO NOTHING, so a user asking again for a run keeps their first record. A budget window's request records each year's report run of its window run.
  • The record names the user the check found, read FOR SHARE without inserting: a browser merged into an account while the simulation service was asked is answered 503 rather than recorded under a re-created user, and one converted in place is recorded as itself.
  • A record that can't be written answers 503 with report_id and run_id.
  • The user's saved reports of the report advance last_run_at to the request's time (never backwards). Saving or repointing a saved detailed-save report through /v2/user-reports sets last_run_at from the owner's records, whatever the caller sends; a saved report naming only a v1 report output keeps the caller's value.
  • Signing in to an existing account copies the browser's run records to it before the browser's user is deleted (a run the account already has keeps the account's record), and resets the involved saved reports' last_run_at from the account's records. The runtime role's existing SELECT, INSERT and DELETE grants on user_report_run_associations cover it; no UPDATE.

Which run is answered.

  • Creates and re-runs read the report back with the run the simulation service's answer names (StartedReportRun for re-runs), which needn't be the newest once sim shares runs; their 502 when it can't be read back names run_id.
  • GET /v2/reports/{report_id}?run_id= reads that run (a report run, or a budget window's budget-window run); any other run answers 404.
  • Without run_id, a read or list answers the requesting user's newest recorded run, else the newest succeeded run that wasn't forced, else the newest run that wasn't forced (a window's: not recalculated). A forced run is never the default.

Names. A report's label and description are the requesting user's saved report's (the one saved last), null otherwise; reports.label and description are no longer read. POST /v2/reports takes label and description as optional and sends neither to the simulation service.

Forced re-runs are for admin users only: 401 without a credential, 403 below admin, before the simulation service is asked.

The decisions it follows

C1 · 24's answers, recorded in the shared-reports design:

  • 2 and 3: a failed run never counts, and a forced re-run is an extra run nobody joins, so the record names whatever run the simulation service answers.
  • 6: the per-user columns leave shared rows; names come from saved reports, the trigger from the user's record, and the create's label and description are stored nowhere.
  • 7: a user sees the run of their own newest request; a newer run someone else started is offered from GET /v2/reports/{report_id}/runs, never in its place; a repeated request keeps the first requested_at.
  • 8: a budget window's request records each year's report run.
  • 10: no user_simulation_run_associations; nothing here writes one.
  • Who may send force: admin users only.

Deploying, with the simulation service's find-or-create (C1 · 27)

  • Order: Share detailed-save reports and runs by key, and record each user's report runs #4303 first (applied in staging and production), then the simulation service's find-or-create by key (PolicyEngine/policyengine-sim-api, C1 · 27), then this. Sim's main still requires label, which this no longer sends, so this must not deploy before C1 · 27.
  • What it reads from sim: the create and re-run answers' report_run_id (and a window's budget_window_run_id) as the run the request ended on, joined or created, always 201; the 409 run_in_progress no longer occurs. Built against those shapes with stand-ins for the simulation service, since C1 · 27 isn't merged yet.
  • Columns still read, and dropped later: each run's trigger (now nullable in Share detailed-save reports and runs by key, and record each user's report runs #4303) and report_runs' cliff columns. A later task drops them with the per-user columns of reports, after this deploys.

Testing

  • Every test this PR adds or changes, run by node ID: 203 pass, the PostgreSQL ones as the runtime role on a disposable PostgreSQL 17 database built at this branch's head, with every role's privileges applied and verified, and alembic -c alembic-v2.ini check clean (no revision here).
  • v2-integration-check.yml's report step runs the six new integration modules after the managed roles get their privileges.
  • lint-imports, python -m scripts.rules validate, python scripts/run_quality_guards.py, ruff, ruff format and mypy pass. Nothing under policyengine_api/data/v2/ changes.
  • Six read-only review rounds; the last two found no bugs. Each round's bugs were fixed test-first (failing test committed first).
  • No deployment code changes. The workflow step reads only V2_MIGRATION_DATABASE_URL, V2_ALEMBIC_DISPOSABLE_TEST and DEPLOYMENT_ENVIRONMENT, literal values in the job's own env: for its own PostgreSQL service container.

🤖 Generated with Claude Code

anth-volk and others added 30 commits November 14, 2026 01:55
GET /v2/reports/{report_id}?run_id= is to answer that run of the report,
whichever run is the newest: a report run of an economy or household
report, or a budget-window run of a budget window, and 404 for any other
run. Today the query refuses run_id (422).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /v2/reports/{report_id}?run_id= answers that run, its status,
versions and, once it has succeeded, its result: one of an economy or
household report's own report runs, or a budget window's budget-window
run with its years. A run that is not one of the report's answers 404.
Without run_id the report is read with its newest run, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once the simulation service finds or creates runs by key, a create or
re-run may end on a run that already exists, which needn't be the
report's newest (a newer forced run, for example), and a re-run while a
run is under way answers that run rather than 409. The report is to be
read back with the run the answer names, and the re-run client is to
read report_run_id (and a budget window's budget_window_run_id) from the
201. Today both answer the newest run, and the client returns nothing.

Adds StartedReportRun, the type the client is to return; the existing
re-run stand-ins answer the run they store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The re-run client reads report_run_id, and a budget window's
budget_window_run_id, from the simulation service's 201 and returns
them (StartedReportRun); an answer naming no run is unreadable, as one
naming another report is. A create or re-run reads the report back with
the run the answer names, so a request that joins an existing run (once
the simulation service finds runs by key) answers that run even when a
newer one exists, and a re-run that joins a run under way answers it
with 201. The route descriptions say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /v2/reports and POST /v2/reports/{report_id}/runs are to take the
user's credential optionally (a bearer token, the unauthenticated-user
header, or a server forwarding either), and record a row in
user_report_run_associations for the run the request ended on: trigger
initial for a create and manual for a re-run, requested_at when the
Public API received the request, and one record per user and run, so a
user asking again keeps their first. A request without a credential
records nothing; a browser credential that is malformed or names a
signed-in user is refused with 401 before the simulation service is
asked. The user's saved reports naming the report get last_run_at from
their newest request, and a record that can't be written answers 503
naming the report and its run.

Adds the record package's session container and an empty service, so
the tests fail on behaviour rather than imports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /v2/reports and POST /v2/reports/{report_id}/runs take the user's
credential optionally (NativeRouteDependencies.optional_user: a bearer
token, the unauthenticated-user header, or a server forwarding either).
With one, the requester is checked before the simulation service is
asked (a browser credential naming a signed-in user answers 401), and
once the service has answered, the new report_run_records service
records the request in one transaction: the user is provisioned as any
user write provisions it, a user_report_run_associations row names the
run the request ended on (trigger initial or manual, requested_at when
the Public API received the request, ON CONFLICT (user_id,
report_run_id) DO NOTHING), and the user's saved reports naming the
report advance last_run_at to that time with GREATEST, so a concurrent
older request never sets it back. A record that can't be written
answers 503 with report_id and report_run_id. Without a credential
nothing is recorded. A budget window's request isn't recorded yet.

The reports routes module drops postponed annotations, since FastAPI
resolves the requester's dependency annotation at runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a signed-out browser signs in to an account that already exists,
the merge moves the browser's saved items and deletes its user, whose
report-run records the foreign key's CASCADE deletes. They are to move
into the account: a run the account already has keeps the account's
record, and the account's saved reports take the time of a request that
moved in when it is later. Today the browser's records are lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a signed-out browser signs in to an existing account, the merge
now copies the browser's user_report_run_associations rows to the
account before deleting the browser's user (whose own rows the CASCADE
removes): each copy keeps its requested_at and trigger, and a run the
account already has a record of keeps the account's. The account's
saved reports of each report a copied record names take its
requested_at as last_run_at when it is later. The runtime role's
SELECT, INSERT and DELETE on the table cover it; no UPDATE grant.

The copy reads the records and inserts them with IDs made in Python,
and last_run_at advances under a WHERE condition rather than GREATEST,
so the statements also run in SQLite, where the user service tests
merge accounts. One insert connector serves a request's records and
the copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving a detailed-save report through /v2/user-reports, or repointing a
saved report to one, is to set last_run_at to the owner's newest
requested_at among the report's runs (null without one), whatever the
caller sends; a patch giving last_run_at or changing only other fields
keeps the server's value. Today the caller's value is stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving a detailed-save report through /v2/user-reports, or repointing
a saved report to one, sets last_run_at to the owner's newest
requested_at among the report's runs (null without one), whatever the
caller sends, and so does a patch that sends last_run_at; a saved
report naming only a v1 report output keeps the caller's value, as v3's
flagship reports send it. The route descriptions say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v2-integration-check.yml's report step also runs
test_v2_report_reads_by_run.py and test_v2_user_report_runs.py, with
the job's existing database settings, after the managed roles get their
privileges; a workflow test requires both. Adds the changelog fragment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The route stand-ins take the requester and keep each one, and new route
tests check that a signed-out browser's create and re-run name its
user, that a request without a credential names none, that a malformed
browser credential is refused before the service, and that the 401 and
the 503 naming an unrecorded run are answered as the integration tests
expect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A sign-in merge is to leave every saved report the account holds with
  the account's newest request for its report: a saved report moved in
  from the browser takes the account's own records, newer or not, even
  for a report the browser never ran.
- A browser merged into an account while the simulation service is asked
  is not recorded again under a re-created user with its ID (503 naming
  the run); one converted in place meanwhile is recorded as itself (201).
- The OpenAPI document is to say the two POST report routes' user
  credential is optional (an empty security requirement).
- A signed-in user's request provisions and records that user (passes:
  coverage the round found missing).

The stand-in and row helpers move to tests/integration/
user_report_run_support.py, so the new test modules share them; the
stand-in can run work during the simulation call, and the client can act
for a given principal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A sign-in merge now gives every saved report the account holds, of a
  report whose saved report moved in or whose record was copied, the
  account's newest request for that report as last_run_at (null without
  one), whatever it held before; read_saved_report_ids is read before the
  saved reports move. The updates go in report ID order.
- The requester check now returns the user it found (CheckedRequester),
  and the record names that user, read FOR SHARE without inserting: a
  browser merged into an account while the simulation service is asked
  is answered 503 rather than recorded under a re-created user with its
  ID (RequesterUserGoneError), and one converted in place is recorded as
  itself. A principal with no user at the check is provisioned as before.
- The two POST report routes' OpenAPI security lists an empty
  requirement, so the user credential reads as optional.
- A record that fails is logged with its cause; both POST routes
  document the 403 for a forwarded machine-client token; the SQLite
  merge test's docstring says only what it checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C1 · 24's answer 8: a window request records each year's report run
for the user, with one requested_at: a create the window run it
created, a re-run the window run it joined, not another. The 503 for an
unrecorded request names the run to read as run_id, which for a window
is its budget-window run. The stand-in creates and joins windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C1 · 24's answer 8: a budget window's create or re-run records, for its
user, the report run of each year of the budget-window run it ended on,
with one requested_at and trigger. ReportRunRequest names either the
report run or the budget-window run, and the record service reads the
window run's years in its own transaction, so a failed read is a failed
record. A re-run decides by the stored report's kind; a window's answer
naming no window run is answered 502, as one that can't be read back.
The 503 for an unrecorded request names the run to read as run_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C1 · 24's answer on force: POST /v2/reports/{report_id}/runs takes
force from an admin user, sends it and records the request, and refuses
it before the simulation service is asked from anyone else: 401 without
a user credential, 403 for a signed-out browser, a standard user or an
author. Today anyone may force.

The tests that forced as a browser or without a credential now force as
an admin (store_signed_in_user), or re-run unforced where one new run
is enough.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C1 · 24's answer on force: POST /v2/reports/{report_id}/runs refuses
force: true, after reading the report and before the simulation service
is asked, from anyone but an admin user: 401 with the bearer challenge
without a user credential (ForcedRerunRequiresSignInError), 403 for a
signed-out browser, a signed-in user not stored yet, or a standard or
author user (ForcedRerunRequiresAdminError). The requester check now
carries the user's stored type. Unforced re-runs need no credential, as
before. The route's description and its 401 and 403 answers say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C1 · 24's answers 7 and 6. A read without run_id is to answer the
requesting user's newest recorded run of the report (for a window, the
window run of its newest recorded year), else the newest succeeded run
that wasn't forced, else the newest unforced run; a forced run is never
the default, so a report with only forced runs reads as not run. Lists
read the same way. A report's label and description are to be the
requesting user's saved report's, null for anyone else or a read naming
no user; a malformed browser credential on a read is refused.

Five existing read tests now expect that default: an older succeeded
run, or the unforced one, is read before a newer pending, running or
forced run, ties between unsucceeded runs still go to the larger ID, and
a report read without a user has no label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C1 · 24's answers 7 and 6.
- A read without run_id answers the requesting user's newest recorded
  run of the report (a window: the window run of its newest recorded
  year), else the report's default run: its newest succeeded run that
  wasn't forced (a window's: not recalculated), else its newest run that
  wasn't forced; a forced run is never the default. Lists read the same
  way. read_report_rows ranks the default; reads_requests reads the
  user's newest requests, their window runs and saved-report labels.
- A report's label and description are the requesting user's saved
  report's name and description (the one saved last), null otherwise;
  ReportDefinitionRead.label and the report items' label are nullable,
  and reports.label is no longer read.
- GET /v2/reports/{report_id} and GET /v2/reports take the optional user
  credential (401 for a malformed or signed-in browser credential); the
  OpenAPI document marks it optional.
- A create or re-run reads its answer back as the user it recorded the
  request for, so a browser converted in place meanwhile still reads.
- StoredReportRows.newest_run and newest_budget_window_run become run
  and budget_window_run, since they hold the run a read answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… into v2plan/c1-28-user-report-runs

# Conflicts:
#	policyengine_api/fastapi_routes/v2/reports/routes.py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A create or re-run whose report can't be read back is to name the run
  in its 502 (run_id, and in the message's read URL), since a read
  without run_id may now answer another run.
- CI's integration step is to run the four modules added since round 1,
  after the managed roles get their privileges.
- Coverage the round found missing (passes): the newest request wins
  whichever run was created last; a recalculated window run is never the
  default; of two saved reports of one report, the one saved last names
  it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The 502 after a create or re-run whose report can't be read back names
  the run the answer named (run_id, and in the read URL of its message),
  null for a window answer naming no window run; a read without run_id
  may answer another run.
- CI's integration step runs the four modules added since round 1, after
  the managed roles get their privileges.
- The household read test expects no label for a read naming no user;
  C1 · 18's 502 test expects the run.
- The report items' and run_id's descriptions say which run a read
  answers rather than "the newest"; the GET routes document the 403 for
  a forwarded machine-client token; the service's docstrings follow.
- The read view takes the requester and the recorded user as two
  parameters rather than one of two types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Two existing tests expected the 502 without run_id: the OpenAPI test
  of the created records' IDs now lists each answer's ID fields, and the
  create integration test expects the run in the body and the read URL.
- The 502 descriptions, the create route's description, the route
  helpers' docstrings and the changelog say the answer names run_id.
- "Newest run" wording left in the stored-report types, transformations
  and run reads now says which run a read answers.
- Tests: the re-run 502 for a window answer naming no window run answers
  a null run_id; the OpenAPI document gives that nullable run_id and the
  reads' 403; the window create 502 names its run; the request-order test
  asks for a run that is neither the default nor the newest.

Not changed: the workflow test reads the workflow as text, as the tests
beside it do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A budget window's re-run whose answer names no window run answers a
  502 with a null run_id that points to the report's run list (GET
  /v2/reports/{report_id}/runs), since run_id=null can't be read, and
  records nothing; today it points to a read of the default run.
- The create 502 test compares run_id with the run the stand-in answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A budget window's re-run whose answer names no window run answers its
  502 with a null run_id and a message pointing to GET
  /v2/reports/{report_id}/runs, which lists the run, rather than to a
  read of the report's default run; the 502's description and the
  service protocol say so.
- Hand-wrapped fragments in the route helpers' docstrings and the create
  route's description are rewrapped.

Not changed: test names that say "newest run" store one run or use a
stand-in, so they still describe what they check.

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

C1 · 24's answer 6, as the user chose: POST /v2/reports stores label and
description nowhere. Both become optional (omitted or null), and the
simulation service's request bodies (economy, household, budget window)
carry neither, since after C1 · 27 sim accepts and ignores them; a
report's name and description come from the user's saved report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C1 · 24's answer 6, as the user chose: POST /v2/reports stores label
and description nowhere. Both are optional (omitted or null) for
economy reports, budget windows and household reports, and a value that
is sent is still checked; the simulation service's request bodies carry
neither, since after C1 · 27 sim accepts and ignores them. A report's
name and description are each user's saved report's. This needs C1 · 27
deployed first, since sim's main still requires label.

Four existing tests that required a label now refuse an empty one
instead, and the household request schema no longer requires it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 3 commits November 14, 2026 04:22
- The re-run 502 for a window answer naming no window run is to say so,
  not that the report couldn't be read back.
- The create request schemas are to say label and description are
  stored nowhere.
- A create without a label is accepted for household reports and budget
  windows too (passes: coverage the round found missing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A budget window's re-run whose answer named no window run says so in
  its 502 and its error, rather than that the report couldn't be read
  back, since no read was tried.
- The create request schemas describe label and description as optional
  and stored nowhere (the household report's in its docstring too).
- Hand-wrapped fragments in the create helper's docstring and the re-run
  route's description are rewrapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The re-run 502's OpenAPI description and the RerunReportUnreadable
  response's and error's docstrings describe the null-run_id case as a
  window answer naming no window run, with no cause, not a failed read.
- The create fields' description is a constant named for what it is,
  and says a report's label and description are the name and
  description of each user's saved report.
- The re-run route's description is rewrapped.

Documentation only; no further review round.

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

codecov Bot commented Nov 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.60494% with 34 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.41%. Comparing base (63cce82) to head (77c940a).

Files with missing lines Patch % Lines
policyengine_api/fastapi_routes/dependencies.py 53.33% 5 Missing and 2 partials ⚠️
...licyengine_api/fastapi_routes/v2/reports/routes.py 87.50% 4 Missing and 1 partial ⚠️
policyengine_api/libs/simulation_report_reruns.py 0.00% 5 Missing ⚠️
...yengine_api/services/v2/stored_reports/services.py 94.62% 2 Missing and 3 partials ⚠️
...engine_api/services/v2/report_run_records/types.py 90.62% 2 Missing and 1 partial ⚠️
...ored_reports/database_connectors/reads_requests.py 86.36% 0 Missing and 3 partials ⚠️
policyengine_api/libs/simulation_reports.py 0.00% 2 Missing ⚠️
...v2/report_run_records/database_connectors/reads.py 91.66% 0 Missing and 2 partials ⚠️
.../report_run_records/database_connectors/creates.py 90.90% 0 Missing and 1 partial ⚠️
...ine_api/services/v2/report_run_records/services.py 97.22% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@                         Coverage Diff                         @@
##           v2plan/c1-25-shared-report-keys    #4313      +/-   ##
===================================================================
- Coverage                            93.45%   93.41%   -0.04%     
===================================================================
  Files                                  369      377       +8     
  Lines                                21478    21823     +345     
  Branches                              3401     3455      +54     
===================================================================
+ Hits                                 20072    20387     +315     
- Misses                                 823      842      +19     
- Partials                               583      594      +11     
Flag Coverage Δ
integration 57.32% <91.60%> (+0.65%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ 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