Skip to content

Compare the v2 manifest's /versions answers on staging deploys, and answer /ping in the entrypoint - #934

Draft
anth-volk wants to merge 7 commits into
mainfrom
v2plan/2d-206-versions-and-ping
Draft

anth-volk wants to merge 7 commits into
mainfrom
v2plan/2d-206-versions-and-ping

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #931

Same-Repository Draft PR Check

  • This PR is a draft.
  • This PR is from a branch in PolicyEngine/policyengine-sim-api, not a personal fork.

Summary

This is the comparison step only. The public GET /versions and GET /versions/{kind} routes still forward to the old gateway, and their answers do not change. Switching those routes to the v2 version manifest is a separate, later change, made after the Public API's v1 economy and budget-window routes are removed (see "Why /versions does not switch here").

  • POST /ping is answered by the Simulation Entrypoint itself: the request's value plus one as incremented, with the same request validation and status codes as before. The old gateway is no longer called for it, so /ping answers even when the old gateway is unreachable.
  • New module policyengine_simulation_entry.v2_versions builds the /versions and /versions/{kind} answers from the v2 version manifest, in the old gateway's response models (VersionsResponse, VersionMap, PolicyEngineBundle). It shares no code with the forwarding routes.
    • Routes name the v2 worker applications.
    • A model version contained in several bundles routes to the newest bundle by version number. The old gateway routes it to the bundle published last, which is the same bundle in staging and production today.
    • latest is the manifest's default version; each country's latest is that bundle's model version.
    • spm_capabilities is empty, because the manifest records no SPM capability.
  • New internal routes GET /internal/v2/versions and GET /internal/v2/versions/{kind} serve those answers. They are public like /versions, left out of the OpenAPI document, and deployed to both environments.
    • An unknown kind gives 404 with the old gateway's message (Unknown version kind: …).
    • A manifest that is not configured, cannot be read, or takes over 5 seconds gives 503 with Retry-After: 10.
    • The manifest is read from the same Modal Dict the Stage 12 path reads, on a thread of its own.
  • Each staging deploy runs .github/scripts/compare_versions_sources.py against its candidate revision, right after the smoke check. It reads /versions, each /versions/{kind} and an unknown kind from both sources, and writes every difference to the job summary.
    • Routes are compared by the PolicyEngine version each application serves, because the old gateway names v1 applications and the manifest names v2 ones.
    • The step only reports. It exits 0 whatever it finds, so it never fails a deploy, because the sources are known to differ today.

Why /versions does not switch here

The Public API reads /versions while it serves requests, not only in its release checks.

  • Application names. The Public API's v1 economy and budget-window routes resolve the worker application through /versions/{country} or /versions/policyengine. They put that name into the key they store results under, and they recalculate a stored result whenever the name differs from the resolved_app_name the old gateway reports. The manifest names v2 applications (policyengine-simulation-v2-py…) while the old gateway reports v1 ones (policyengine-simulation-py…), so switching while those routes exist would recalculate on every read. The switch therefore comes in a later change, after those routes are removed.
  • SPM capabilities. The Public API reads spm_capabilities to validate US SPM selections, and its release check (--check-installed-spm) requires a capability for the bundle it ships. The v2 manifest records none today. It will record them as part of the later switch, not in this PR.
  • Versions older than the manifest. Production's routing state also lists versions published before the v2 manifest existed: 4.18.3, 4.18.5, 4.18.7, 4.18.8, 4.18.9, 4.20.3 and 4.22.0, their model-version routes, and some older per-country applications. What /versions answers for them is decided with the later switch, once request logs show whether anything still asks for them.

The same comparison, run locally on answers recorded from both environments, found:

  • Staging: the same versions and routes in both sources. The only differences were the missing SPM capabilities, and dataset digests that only the manifest records for four older versions.
  • Production: 77 differences, mostly the older versions above.

Choices still open for review

These choices were made in this PR and are open for a reviewer to change:

  1. The staging comparison only reports. It exits 0 whatever it finds, so it never fails a deploy while the sources are known to differ.
  2. /ping is answered by the entrypoint without a staging comparison. Its answer does not come from the manifest. Unit tests and the existing deploy smoke check (incremented == 2) show it gives the old gateway's answer.
  3. A model version contained in several bundles routes to the newest bundle by version number. The old gateway routes it to the bundle published last, which is the same bundle in staging and production today.

Before this PR may merge

Environment variables

No new variables. The internal routes use values the entrypoint revision already receives whenever deploy_stage12_v2 is true, which it is for both beta and prod:

Variable Provider How it reaches the code
STAGE12_V2_MANIFEST_NAME Literal simulation-api-v2-version-manifest in deploy-cloud-run-simulation-entry.sh The script's --env-vars-file for gcloud run deploy, so it is a runtime environment variable on the revision
STAGE12_V2_MANIFEST_ENVIRONMENT The reusable workflow input modal_environment (staging or main) Job env: MODAL_ENVIRONMENT in deploy_entrypoint, then the deploy script's --env-vars-file
MODAL_TOKEN_ID, MODAL_TOKEN_SECRET Secret Manager secrets named by the beta/prod GitHub environment variables MODAL_TOKEN_ID_SECRET_NAME and MODAL_TOKEN_SECRET_SECRET_NAME Job env: in deploy_entrypoint, then the deploy script pins each secret's newest enabled version and mounts it with --set-secrets; the Modal client reads both from the environment
CANDIDATE_URL (comparison step) Job output deploy_entrypoint.outputs.candidate_url Step env: in the integration job, passed to the script as its argument
GITHUB_STEP_SUMMARY (comparison step) The GitHub Actions runner Set by the runner for every step

Not verified: that the runtime's Modal token can read the manifest Dict in each environment. The Stage 12 code in the same revision already reads that Dict with the same token, but the read was not tested for this PR.

Testing

Run locally on every commit; all passed:

  • uv tool run ruff@0.15.22 format --check and uv tool run ruff@0.15.22 check .
  • python scripts/run_quality_guards.py
  • python -m scripts.rules pyright --sync projects/policyengine-simulation-entry
  • uv run --frozen pytest tests/ in projects/policyengine-simulation-entry: 809 passed on the branch head
  • The executor tests that read the deploy workflow (test_modal_scripts.py, test_update_v2_version_manifest.py, test_update_version_registry.py): 347 passed

New tests:

  • test_v2_versions.py: the manifest's answers, including a check against the shape of the gateway's recorded answer.
  • test_v2_versions_routes.py: the internal routes and the manifest source.
  • test_compare_versions_sources.py: the comparison logic, whole runs against a local HTTP server, and the workflow step.
  • test_app.py: /ping.

The comparison was also run end to end against the app on 127.0.0.1, serving answers recorded from staging and production.

🤖 Generated with Claude Code

anth-volk and others added 7 commits October 27, 2026 17:05
The old gateway's /ping returns the request's value plus one. The
entrypoint now gives that same answer itself, with the same request
validation, status code and response body, so the route no longer
depends on the old gateway being reachable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add a v2-side module that builds the GET /versions and
GET /versions/{kind} answers, in the old gateway's response models, from
the v2 version manifest instead of v1's routing state. It shares no code
with the routes that forward to the old gateway.

The manifest names v2 worker applications, routes a model version that
several bundles contain to the newest of them, takes latest from its
default version, and records no SPM capability, so spm_capabilities is
empty. The tests pin each of these and check the answer against the
shape of the gateway's recorded answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /internal/v2/versions and GET /internal/v2/versions/{kind} give the
answers /versions would give from the v2 version manifest, read from the
Modal Dict the deploy publishes it to. /versions itself still forwards
to the old gateway, so a staging deploy can compare the two before the
public routes change.

The routes are public like /versions and left out of OpenAPI. An
unknown kind is a 404 with the old gateway's detail; a manifest that is
not configured, cannot be read or takes over 5 seconds gives a 503.
Reads run on the source's own thread, so a stalled read does not hold
threads of the default executor the Stage 12 path uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The staging deploy now runs .github/scripts/compare_versions_sources.py
against its candidate revision after the smoke check. It reads
/versions, /versions/{kind} and an unknown kind from both the old
gateway (through the entrypoint's forwarding routes) and the v2
manifest (through /internal/v2/versions), and writes every difference
to the job summary.

Routes are compared by the PolicyEngine version each application
serves, because the gateway names v1 applications and the manifest v2
ones. The report is for information only and exits 0 whatever it finds,
since the two sources are known to differ until the manifest records
everything the gateway reports; only a missing base URL is an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The route tests imported their manifest builders from another test
module. They now come from tests/v2_versions_support.py, which both test
modules use, as the testing guidance asks for shared helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The per-kind comparison read any body that was not a JSON object as an
empty route map, so two such answers counted as a match. Every answer
the comparison reads must now be HTTP 200 with a JSON object; any other
body is listed as a body difference and is not compared further.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
create_app chose the source with "or", so an injected source that
evaluated as false was silently replaced by the one built from the
settings. It now builds a source from the settings only when none is
injected.

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Compare the v2 manifest's /versions answers with the old gateway's, and answer /ping in the entrypoint

1 participant