Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
14a6624
Derive every v2 country list from one list in code
anth-volk Oct 21, 2026
2729c37
Record each served country in a countries table that country columns …
anth-volk Oct 21, 2026
b7cb97c
Add a check that a deployment calls its own environment's simulation …
anth-volk Oct 21, 2026
ac9d7a0
Check each release calls its own environment's simulation service
anth-volk Oct 21, 2026
0d30696
Serve the countries table at GET /v2/countries
anth-volk Oct 22, 2026
f9a19c1
Refuse to start when the countries table and the country list differ
anth-volk Oct 22, 2026
6b8cbd2
Stop startup only when the countries table answers with a disagreement
anth-volk Oct 22, 2026
975c80f
Keep the startup check's log tests independent of earlier Alembic tests
anth-volk Oct 22, 2026
dc08d15
Merge remote-tracking branch 'origin/v2plan/1g-126-countries-table' i…
anth-volk Oct 27, 2026
f7d89e0
Merge remote-tracking branch 'origin/v2plan/1j-141-own-environment-ch…
anth-volk Oct 27, 2026
8417d1c
Record each catalog version's SPM availability from the simulation se…
anth-volk Oct 27, 2026
fcad8fd
Serve each catalog version's SPM availability at /v2/model-catalog
anth-volk Oct 27, 2026
54071cd
Convert stored households and policies to what the simulation service…
anth-volk Oct 27, 2026
d12b804
Calculate stored households through /v2/household-calculations
anth-volk Oct 27, 2026
f6d1d4e
Check household calculations and their parity with /calculate in staging
anth-volk Oct 27, 2026
c66b1d0
Read simulation service answers through the generated models
anth-volk Oct 27, 2026
0f9bf9f
Refuse axes the simulation service would refuse, before sending them
anth-volk Oct 27, 2026
0c9b65c
Calculate each household for its default year, and take no force
anth-volk Oct 27, 2026
aaf0df3
Open the catalog reader only to submit, and reuse the household colle…
anth-volk Oct 27, 2026
d910153
Leave SPM availability unrecorded until the service serves the version
anth-volk Oct 27, 2026
f6f5f0c
Compare reforms and added variables with API v1 in the staging parity…
anth-volk Oct 27, 2026
0a3851f
Keep the household's key order to and from the simulation service
anth-volk Oct 27, 2026
d82e798
Build the simulation service client only once a request reaches it
anth-volk Oct 27, 2026
7ed16f2
Vary the household's default year when an axis names no period
anth-volk Oct 27, 2026
538499d
Register the household calculation routes with the household route group
anth-volk Oct 27, 2026
bdbab60
Check the simulation service URL first, and never fail on its versions
anth-volk Oct 27, 2026
b54f7ef
Send a live check's calculation again after a 5xx answer
anth-volk Oct 27, 2026
4108a80
Log household calculations with the sources and compute they use
anth-volk Oct 27, 2026
e3b5f8d
Drop the unused generated client wrapper and correct two descriptions
anth-volk Oct 27, 2026
243c2d9
Retry only short outages in the live checks, and compare warnings in …
anth-volk Oct 27, 2026
ba761d4
Log household calculations without checking flags they do not use
anth-volk Oct 27, 2026
d695268
Type the simulation answer reader without a suppression
anth-volk Oct 27, 2026
220cf1e
Send the simulation service household and policy IDs, not their values
anth-volk Oct 28, 2026
78219f3
Send axes as given, now that the household function fills a missing p…
anth-volk Oct 28, 2026
2542739
Pass the simulation service's 422 refusals through with their reasons
anth-volk Oct 28, 2026
21ad205
Name the parity household's person `you`, so both sides add one year'…
anth-volk Oct 28, 2026
5184700
Check that axis periods are forwarded unchanged, and name the year check
anth-volk Oct 28, 2026
109d626
Answer 503 for a simulation refusal of another shape
anth-volk Oct 28, 2026
4c515e2
Describe the remaining 503s, the per-field 422, and test the sent def…
anth-volk Oct 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/scripts/check_simulation_entrypoint_environment.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env bash

# Check that a deployment environment calls its own Simulation Entrypoint:
# staging must call the staging service and production the production
# service. The entrypoint names the Cloud Run revision that served a request
# in its x-policyengine-simulation-revision response header, and a Cloud Run
# revision name is its service name followed by a five-digit number and a
# three-character suffix.
#
# The old gateway (SIM_ENTRYPOINT=old_gateway_direct) is one shared Modal app
# that reports no environment, so it is not checked.
#
# Required env vars: DEPLOYMENT_ENVIRONMENT (staging or production),
# SIM_ENTRYPOINT, and the URL variable that SIM_ENTRYPOINT selects.
# Exit codes: 0 when the environments match, 1 when they do not or the
# response names no revision, 2 for an unknown DEPLOYMENT_ENVIRONMENT, and
# curl's own exit code when the request fails.

set -euo pipefail

source .github/scripts/simulation_entrypoint_env.sh

deployment_environment="${DEPLOYMENT_ENVIRONMENT:-}"
case "${deployment_environment}" in
staging)
expected_service="policyengine-simulation-entry-staging"
;;
production)
expected_service="policyengine-simulation-entry"
;;
*)
printf 'DEPLOYMENT_ENVIRONMENT must be staging or production, not %q\n' \
"${deployment_environment}" >&2
exit 2
;;
esac

if [[ "${SIM_ENTRYPOINT:-}" == "old_gateway_direct" ]]; then
printf 'SIM_ENTRYPOINT=old_gateway_direct: the shared old gateway reports no environment, so there is nothing to check\n'
exit 0
fi

simulation_url="$(simulation_entrypoint_selected_url "${SIM_ENTRYPOINT:-}")"
simulation_url="${simulation_url%/}"

health_response="$(
curl --fail --silent --show-error --include \
--connect-timeout 10 --max-time 60 \
"${simulation_url}/health"
)"

# With --include, curl prints every response's headers; the last revision
# header belongs to the final response.
served_revision="$(
awk '
tolower($1) == "x-policyengine-simulation-revision:" {
gsub("\r", "", $2)
print $2
}
' <<<"${health_response}" |
tail -n 1
)"

if [[ -z "${served_revision}" ]]; then
printf '%s/health did not name a Simulation Entrypoint revision; the %s deployment must call the stable URL of %s\n' \
"${simulation_url}" "${deployment_environment}" "${expected_service}" >&2
exit 1
fi

if ! [[ "${served_revision}" =~ ^(.+)-[0-9]{5}-[a-z0-9]{3}$ ]]; then
printf '%s, served by %s, is not a Cloud Run revision name\n' \
"${served_revision}" "${simulation_url}" >&2
exit 1
fi
served_service="${BASH_REMATCH[1]}"

if [[ "${served_service}" != "${expected_service}" ]]; then
printf 'The %s deployment calls %s, which revision %s of %s serves; it must call %s\n' \
"${deployment_environment}" "${simulation_url}" "${served_revision}" \
"${served_service}" "${expected_service}" >&2
exit 1
fi

printf 'The %s deployment calls %s, served by revision %s of %s\n' \
"${deployment_environment}" "${simulation_url}" "${served_revision}" \
"${served_service}"
2 changes: 2 additions & 0 deletions .github/scripts/run_live_staging_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ python -m pytest \
tests/integration/test_live_budget_window_cache.py \
tests/integration/test_live_calculate.py \
tests/integration/test_live_economy.py \
tests/integration/test_live_v2_household_calculations.py \
tests/integration/test_live_v2_household_parity.py \
-v
8 changes: 7 additions & 1 deletion .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,14 @@ jobs:
&& (github.event.head_commit.message == 'Update PolicyEngine API')
environment: staging
env:
DEPLOYMENT_ENVIRONMENT: staging
SIM_ENTRYPOINT: ${{ vars.SIM_ENTRYPOINT }}
SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Check the simulation service belongs to staging
run: bash .github/scripts/check_simulation_entrypoint_environment.sh
- name: Install jq
run: sudo apt-get install -y jq
- name: Set up uv for installed bundle validation
Expand Down Expand Up @@ -339,7 +342,7 @@ jobs:
parallel-live-simulation-tests-staging-cloud-run:
name: Run live simulations against the staging candidate
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 30
needs: deploy-cloud-run-staging
if: |
(github.repository == 'PolicyEngine/policyengine-api')
Expand Down Expand Up @@ -438,11 +441,14 @@ jobs:
&& (github.event.head_commit.message == 'Update PolicyEngine API')
environment: production
env:
DEPLOYMENT_ENVIRONMENT: production
SIM_ENTRYPOINT: ${{ vars.SIM_ENTRYPOINT }}
SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Check the simulation service belongs to production
run: bash .github/scripts/check_simulation_entrypoint_environment.sh
- name: Install jq
run: sudo apt-get install -y jq
- name: Set up uv for installed bundle validation
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/seed-v2-database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,6 @@ jobs:
# Reads the certified UK data release, whose repository is private,
# to check each new catalog version's region filters.
HUGGING_FACE_TOKEN: ${{ secrets.PE_UK_PRIVATE_HF_READ_TOKEN }}
# Each version's SPM availability comes from this environment's
# simulation service's public versions answer.
SIMULATION_ENTRYPOINT_URL: ${{ secrets.SIMULATION_ENTRYPOINT_URL }}
4 changes: 2 additions & 2 deletions .github/workflows/v2-integration-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ jobs:
run: uv run coverage run --branch -m pytest -q tests/integration/test_v2_catalog_installed.py tests/integration/test_v2_catalog_metadata_parity.py
env:
RUN_V2_CATALOG_COMPATIBILITY: "1"
- name: Test v2 metadata publication and resource routes
run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_catalog_publication.py tests/integration/test_v2_metadata_routes.py
- name: Test v2 metadata publication, resource routes and the countries list
run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_catalog_publication.py tests/integration/test_v2_catalog_spm_availability.py tests/integration/test_v2_metadata_routes.py tests/integration/test_v2_country_routes.py
- name: Test v2 policy persistence and immediate v1 mirroring
run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_policy_persistence.py tests/integration/test_v1_policy_dual_write.py tests/integration/test_mysql_policy_dual_write.py tests/integration/test_v2_user_policy_mirroring.py tests/integration/test_v1_user_policy_dual_write.py
- name: Test signed-in identity provisioning and user associations
Expand Down
1 change: 1 addition & 0 deletions changelog.d/catalog-spm-availability.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Each published catalog version now records whether it offers SPM settings, with their schema and certified defaults, from the simulation service's public versions answer; the catalog publication step reads `SIMULATION_ENTRYPOINT_URL`, and a version published earlier gets it on the next publication run of its PolicyEngine.py version.
1 change: 1 addition & 0 deletions changelog.d/model-catalog-spm-availability.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`GET /v2/model-catalog` now returns `spm`: whether the catalog version offers SPM settings for a household, with their JSON schema and certified defaults when it does.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Each release now checks, in its staging and production model-version alignment jobs, that the environment's simulation URL is served by that environment's own Simulation Entrypoint service, and fails otherwise; staging must call the staging simulation service, and production the production one.
1 change: 1 addition & 0 deletions changelog.d/v2-countries-route.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added `GET /v2/countries`, which lists every row of the countries table: each served country's ID and whether it supports household and society-wide calculations.
1 change: 1 addition & 0 deletions changelog.d/v2-countries-startup-check.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Public API refuses to start when the v2 `countries` table and its country list in code name different countries, or when the table or the privilege to read it is missing. The check runs on Cloud Run and wherever a v2 runtime database is configured; when it cannot query the table, it logs why and startup continues.
1 change: 1 addition & 0 deletions changelog.d/v2-countries-table.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added a v2 `countries` table that records, for each country the Public API serves, whether it supports household and society-wide calculations. Its revision lists the US and the UK, both with both. Users' primary country and the country of every v2 household, policy and user association now reference this table instead of a fixed US-and-UK check.
1 change: 1 addition & 0 deletions changelog.d/v2-household-calculations.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`POST /v2/household-calculations` sends a stored household, under a stored policy or current law, with optional axes, to the simulation service for calculation, and `GET /v2/household-calculations/{calculation_id}` returns its status and, once it succeeded, the calculated household with its warnings and, in the US, the SPM selection applied and its receipt. Neither needs an account; a country whose `countries` row does not allow household calculations is refused with 422.
29 changes: 24 additions & 5 deletions docs/engineering/migration-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi
| Metric | Count |
| --- | ---: |
| route group count | 9 |
| workflow count | 16 |
| request count | 92 |
| workflow count | 18 |
| request count | 95 |
| db entity count | 6 |
| sim flow count | 3 |

Expand All @@ -18,9 +18,9 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi
| --- | --- | --- | --- |
| `health` | `health`, `simulation-gateway-check`, `liveness-check`, `readiness-check` | `none` | `none` |
| `specification` | `specification` | `none` | `none` |
| `metadata` | `metadata`, `datasets`, `economy-options`, `model-catalog`, `parameter-values`, `parameters`, `regions`, `tax-benefit-model-versions`, `tax-benefit-models`, `variables` | `metadata` | `none` |
| `metadata` | `metadata`, `countries`, `datasets`, `economy-options`, `model-catalog`, `parameter-values`, `parameters`, `regions`, `tax-benefit-model-versions`, `tax-benefit-models`, `variables` | `metadata` | `none` |
| `policy` | `policy`, `policies`, `user-policy`, `user-policies` | `policy` | `none` |
| `household` | `household`, `households`, `user-household`, `user-households`, `calculate`, `calculate-full` | `household` | `household` |
| `household` | `household`, `households`, `household-calculations`, `user-household`, `user-households`, `calculate`, `calculate-full` | `household` | `household` |
| `economy` | `economy` | `simulation` | `economy` |
| `simulation` | `simulation`, `simulations`, `user-simulations` | `simulation` | `economy` |
| `report` | `report`, `user-reports`, `shared-reports` | `report` | `report` |
Expand Down Expand Up @@ -100,6 +100,16 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi
| `GET` | `/v2/households/{household_id}?country_id=us` | 200 | `household` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.default_year`, `result.item.household_data`, `result.item.created_at`, `result.item.updated_at` |
| `GET` | `/v2/households?country_id=us` | 200 | `household` | none | `status`, `message`, `result.items`, `result.offset`, `result.limit`, `result.has_more` |

### `household_calculations_v2`

- Current contract: `typed_v2_resources`
- Future owner: Household calculations on v2

| Method | Path | Status | Route group | Required headers | Stable response fields |
| --- | --- | ---: | --- | --- | --- |
| `POST` | `/v2/household-calculations` | 202 | `household` | none | `status`, `message`, `result.calculation_id`, `result.country_id`, `result.household_id`, `result.policy_id`, `result.year`, `result.catalog_version_id`, `result.status`, `result.created_at` |
| `GET` | `/v2/household-calculations/{calculation_id}` | 200 | `household` | none | `status`, `message`, `result.calculation_id`, `result.status`, `result.completed_at`, `result.result.household`, `result.result.warnings`, `result.result.spm_config`, `result.result.spm_provenance`, `result.result.policyengine_version`, `result.error` |

### `user_household_associations_v2`

- Current contract: `typed_v2_resources`
Expand Down Expand Up @@ -190,6 +200,15 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi
| `GET` | `/us/metadata` | 200 | `metadata` | none | `status`, `result.current_law_id`, `result.economy_options.region`, `result.economy_options.time_period` |
| `GET` | `/uk/metadata` | 200 | `metadata` | none | `status`, `result.current_law_id`, `result.economy_options.region`, `result.economy_options.time_period` |

### `country_selection_v2`

- Current contract: `typed_v2_resources`
- Future owner: Countries table and one country list in code

| Method | Path | Status | Route group | Required headers | Stable response fields |
| --- | --- | ---: | --- | --- | --- |
| `GET` | `/v2/countries` | 200 | `metadata` | none | `status`, `message`, `result.items` |

### `metadata_resources_v2_preview`

- Current contract: `typed_v2_resources`
Expand All @@ -215,7 +234,7 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi
| `GET` | `/v2/regions/by-code/state/ca?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.item` |
| `GET` | `/v2/tax-benefit-models/by-country/us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.model`, `result.model_version` |
| `GET` | `/v2/economy-options?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.current_law_id`, `result.region`, `result.time_period`, `result.datasets` |
| `GET` | `/v2/model-catalog?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.catalog_format`, `result.entities`, `result.basic_inputs`, `result.regions`, `result.datasets`, `result.economy_options` |
| `GET` | `/v2/model-catalog?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.catalog_format`, `result.entities`, `result.basic_inputs`, `result.regions`, `result.datasets`, `result.economy_options`, `result.spm` |
| `GET` | `/v2/model-catalog/variables?country_id=us` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.catalog_format`, `result.variables`, `result.part_parameter_values` |
| `GET` | `/v2/model-catalog/parameters?country_id=us&name=gov.irs.income.bracket.rates.1` | 200 | `metadata` | none | `status`, `message`, `result.policyengine_version`, `result.catalog_format`, `result.parameters`, `result.node_labels`, `result.missing_names` |

Expand Down
30 changes: 30 additions & 0 deletions docs/engineering/skills/alembic-migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,3 +342,33 @@ revision's inserts, reads, updates and deletes through it.
sign-in merge, report creation and the Stage 10 saved-policy copy as the
runtime role with the table named `user_policies`, so every statement goes
through the view.

### API v2 countries rows

Revision `fb1a3e40d3d6` was autogenerated after adding the `countries` table
and replacing the US-and-UK checks on `users.primary_country` and on the
`country_id` of households, policies and the four user association tables
with foreign keys to it. It contains one hand-written operation: it inserts
the `uk` and `us` rows, both household and society-wide capable, between the
generated table creation and the generated foreign keys, which otherwise fail
on any database whose rows already name either country. The rows are exactly
the values the replaced checks allowed. The generated downgrade removes them
with the table, and its restored checks fail if any record names another
country.

Inserting rows is a data operation, which this skill otherwise forbids. The
maintainers approved this insert as a one-off exception, because the new
foreign keys need the rows in the same revision. Unit tests pin the
operation, and the v2 Postgres lifecycle test covers an upgrade that keeps
existing rows, the downgrade, a downgrade refused by another country's
records, and the re-upgrade.

The table must list exactly the countries in
`policyengine_api/supported_countries.py`: the Public API refuses to start
when they differ (`policyengine_api/startup_country_check.py`). Listing
another country later needs a revision that inserts its row, and the same
human migration decision. The deploy runs that revision before it starts
the new release, so instances of the previous release that start afterwards,
and a rollback to it, refuse to start until the revision is downgraded. The
module docstring of `supported_countries.py` lists the other checks a new
country must widen.
74 changes: 71 additions & 3 deletions docs/generated/migration_contracts.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"metadata": {
"db_entity_count": 6,
"request_count": 92,
"request_count": 95,
"route_group_count": 9,
"sim_flow_count": 3,
"workflow_count": 16
"workflow_count": 18
},
"route_groups": [
{
Expand All @@ -31,6 +31,7 @@
"name": "metadata",
"path_segments": [
"metadata",
"countries",
"datasets",
"economy-options",
"model-catalog",
Expand Down Expand Up @@ -60,6 +61,7 @@
"path_segments": [
"household",
"households",
"household-calculations",
"user-household",
"user-households",
"calculate",
Expand Down Expand Up @@ -537,6 +539,52 @@
}
]
},
{
"current_contract": "typed_v2_resources",
"future_owner_pr": "Household calculations on v2",
"name": "household_calculations_v2",
"requests": [
{
"expected_status": 202,
"headers": [],
"method": "POST",
"path": "/v2/household-calculations",
"route_group": "household",
"stable_response_fields": [
"status",
"message",
"result.calculation_id",
"result.country_id",
"result.household_id",
"result.policy_id",
"result.year",
"result.catalog_version_id",
"result.status",
"result.created_at"
]
},
{
"expected_status": 200,
"headers": [],
"method": "GET",
"path": "/v2/household-calculations/{calculation_id}",
"route_group": "household",
"stable_response_fields": [
"status",
"message",
"result.calculation_id",
"result.status",
"result.completed_at",
"result.result.household",
"result.result.warnings",
"result.result.spm_config",
"result.result.spm_provenance",
"result.result.policyengine_version",
"result.error"
]
}
]
},
{
"current_contract": "typed_v2_resources",
"future_owner_pr": "PR 12A: User Login and v1-Anchored User Associations",
Expand Down Expand Up @@ -1349,6 +1397,25 @@
}
]
},
{
"current_contract": "typed_v2_resources",
"future_owner_pr": "Countries table and one country list in code",
"name": "country_selection_v2",
"requests": [
{
"expected_status": 200,
"headers": [],
"method": "GET",
"path": "/v2/countries",
"route_group": "metadata",
"stable_response_fields": [
"status",
"message",
"result.items"
]
}
]
},
{
"current_contract": "typed_v2_resources",
"future_owner_pr": "Later metadata read cutover and v2 route-prefix removal",
Expand Down Expand Up @@ -1631,7 +1698,8 @@
"result.basic_inputs",
"result.regions",
"result.datasets",
"result.economy_options"
"result.economy_options",
"result.spm"
]
},
{
Expand Down
Loading
Loading