Skip to content

Gate releases on a numerical sentinel and make them reversible - #744

Draft
MaxGhenis wants to merge 6 commits into
w4/release-identity-sentinelfrom
w4/sentinel-rollback
Draft

MaxGhenis wants to merge 6 commits into
w4/release-identity-sentinelfrom
w4/sentinel-rollback

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Draft, stacked on #743 (base w4/release-identity-sentinel). Part of W4 in the UK Autumn Budget 2026 CI plan: production readiness means the expected /versions identity plus a numerical HTTP sentinel, with a tested rollback.

What changes

Numerical release sentinel (src/modal/utils/release_sentinel.py)

  • New sentinel_expected job, which runs in parallel with the service deploys. It runs the sentinel request in-process through run_simulation_impl, the worker's own implementation. It uses the exact bundle this deployment installs and the payload the gateway forwards (worker_payload is the gateway's model_dump). It then checks that the result names the deployed policyengine version, UK model and dataset SHA-256.
  • The integration job checks that the candidate URL reproduces those numbers; promote_entrypoint checks the stable URL. In both, the request is pinned to the deployed policyengine version. They compare the served bundle identity and budget.{budgetary_impact, tax_revenue_impact, benefit_spending_impact, baseline_net_income}, at relative 1e-9 / absolute £0.01.
  • UK requests are not segmented, so the served worker runs the same code on the same payload. A difference means the stack is not serving the bundle that was built: wrong app, wrong data, a stale cache or a broken route.
  • The case is basic rate 21% from 1 January 2026, for 2026. Locally on the 6.2.3 bundle it gives £6.68bn in about 11 s with a 2.8 GB peak. I chose 1 January deliberately: on 6.2.3 a 6 April-dated reform gives £0 for 2026 (the dated-reform defect W1 is fixing), and a value that is zero on both sides would prove nothing.
  • CI needs PE_UK_PRIVATE_HF_READ_TOKEN and GCP_CREDENTIALS_JSON (the constituency lookup is read from GCS). The environments already hold both.

Routing revert on failure

  • update_routing records latest.policyengine before and after publishing, and whether this release should now be latest (newer than the previous latest, or forced). It fails if publishing should have advanced latest but did not.
  • The identity smoke (Expose the certified dataset SHA-256 and assert the served identity at deploy #743) requires latest to route to the release only when it should advance it, so an intentional older-version deploy still verifies its own route and identity.
  • New revert_routing job: if integration, the authenticated tests or the requested promotion fail (or the run is cancelled), it restores the previous latest and withdraws the failed release. The restore applies only if latest is still that release, so it never overwrites a newer one.
  • update_version_registry gains --print-latest and --restore-latest with --expected-latest and --withdraw. Withdrawn country routes go back to the newest remaining bundle with that model version, or are removed.

Rollback target and workflow

  • Revision cleanup keeps the revision that served before the deploy (SIMULATION_ENTRYPOINT_ROLLBACK_REVISION), so there is something to roll back to.
  • New simulation-rollback.yml (workflow_dispatch). Its inputs are passed through the environment and validated before anything changes. It restores a published release, optionally withdraws one and moves Cloud Run traffic to a retained revision, then runs the identity smoke against the stable URL. It shares the deployments' concurrency group.

Invariants (tested)

  • Publishing release R and then restoring the previous latest with R withdrawn returns routes, bundles and latest exactly to their pre-R state. This is a Hypothesis property over release sequences.
  • After any restore, every route names an app that still has a policyengine route and manifest, and no route reaches a withdrawn app.
  • A conditional restore never changes a state whose latest is something else.
  • The sentinel passes only when the served identity matches and every value agrees. Agreement is symmetric and admits only reordering error (Hypothesis). A failed, rejected, missing or late job fails.
  • The sentinel's in-process run receives exactly the gateway-forwarded payload (differential test against the gateway model).
  • Rollback inputs never appear in shell source; malformed or injection-shaped inputs are refused before any change.

Tests

  • executor: 748 passed, 22 skipped.
  • entry: 190 passed.
  • actionlint and shellcheck are clean on the changed workflows and scripts. The one actionlint report is pre-existing, in pr.yml.
  • hypothesis is added to the executor's test extra (lock: one package added).

Review

Independent review (GPT-6.1 Sol) is running at adc244f.

Still to do

  • Staging drill after merge. The beta deploy exercises sentinel_expected and the candidate check. Then a manual simulation-rollback.yml run on beta restores the previous release, checks the served identity, and re-promotes. I'll record the run links here.
  • Merge order: Expose the certified dataset SHA-256 and assert the served identity at deploy #743 first, then this PR, retargeted to main.
  • I'm filing two pre-existing findings separately:
    • budget.households sums household_weight through the weighted-sum helper, giving about 8.3e11 for the UK;
    • on the deployed 6.2.3 pin, April-dated UK reforms score £0 for that year. The policyengine.py side is W1's fix.

🤖 Generated with Claude Code

MaxGhenis added a commit that referenced this pull request Oct 11, 2026
Review findings on #743:
- H1: /versions route maps hold versions as latest and app names under each
  version key. The smoke now checks the release's own route and UK bundle
  identity, that its UK model routes to the same app, its dataset digest,
  and, with EXPECTED_LATEST=true, that latest routes to that version and
  model. Its test fixture is now the gateway's real /versions output, kept
  current by a gateway differential test.
- M1: one historical bundle with an unresolvable dataset is reported
  without a dataset instead of failing /versions.
- M2: tests pin the EXPECTED_* wiring, prepare's dataset output and the
  updater's RELEASE_PAYLOAD.
- M3: verify-dispatch fails closed when the installed bundle cannot report a
  field the dispatch names (except the certificate digest), and reports
  dispatch_verified only when something was compared.
- policyengine.py sends data_release_version as the data package version;
  compare it with a new uk_data_package_version output (the record's
  build-style version never matched). A differential test derives a real
  dispatch from the locked bundle and verifies it.
- Qualification fields are validated; a pair marked not promotable, or with
  a verdict that is never promoted, is refused. Digests compare
  case-insensitively and malformed bundle digests are refused.
- Docs: /versions reports the registry's identity for each bundle; the
  numerical sentinel (#744) checks what the worker computes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis force-pushed the w4/sentinel-rollback branch from 87ade7a to adc244f Compare October 11, 2026 22:02
MaxGhenis and others added 3 commits October 11, 2026 23:03
- release_sentinel computes UK sentinel values in-process with the exact
  bundle being deployed (run_simulation_impl, the worker's own
  implementation, on the payload the gateway forwards) and checks that the
  candidate and then the stable service reproduce them, with the deployed
  policyengine version, UK model and dataset SHA-256.
- update_routing records latest before and after publishing; revert_routing
  restores the previous latest and withdraws the failed release when any
  later gate fails or the run is cancelled, conditional on latest still
  being that release.
- update_version_registry gains --print-latest and --restore-latest with
  --expected-latest and --withdraw; publish then rollback returns routing to
  its prior state (property-tested).
- Revision cleanup keeps the previous serving revision as a rollback target.
- simulation-rollback.yml restores a published release (and optionally a
  retained Cloud Run revision) on demand and verifies the served identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
update_routing records whether this release should now be latest (newer
than the previous latest, or forced) and fails if publishing should have
advanced latest but did not. The deployed-stack and stable-service smokes
require latest to route to the release only then, so an intentional
older-version deploy still verifies its own route and identity. The
rollback workflow requires latest to route to the restored release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- update_routing writes a routing plan (the state before and after
  publishing, with digests) and keeps it as an artifact before it changes
  anything, then applies it only onto its own starting state.
- revert_routing restores the plan's starting state when routing failed
  after planning, any later gate failed, or the run was cancelled. It acts
  only while the live state is still what this release published, and a
  plan that was never applied restores nothing. First publications, older
  releases that do not move latest, and every route the release touched
  are undone exactly; nothing is inferred from remaining manifests.
- --withdraw and predecessor guessing are gone. The manual rollback
  restores a deployment's kept plan by run id, or points latest at a
  published version, then verifies the whole identity latest routes to.
- The deploy smoke accepts EXPECTED_UK_DATASET_SHA256=unrecorded for
  releases published before digests were recorded, requiring none served.
- The sentinel fails a complete job whose result is not an object.
- Tests evaluate the revert condition and EXPECTED_LATEST as GitHub
  expressions, parse the workflows, and kill the surviving mutants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis force-pushed the w4/sentinel-rollback branch from adc244f to 97a0412 Compare October 12, 2026 03:18
MaxGhenis and others added 3 commits October 12, 2026 05:30
…ithfully

- Every writer of the routing state (apply, restore, restore latest,
  publish, legacy seed) holds a write lock taken with Modal's atomic
  put(skip_if_exists=True), so a check is never stale by the time its
  write lands. A lock left by a writer that died expires after 15 minutes
  and is broken by the next writer.
- The manual rollback passes every run attempt's plan, oldest first, and
  the restore undoes each attempt's change that is still live, newest
  first, skipping plans that changed nothing, so a failed retry cannot
  hide the publication it followed.
- The workflow tests evaluate conditions with GitHub's semantics (an
  implicit success() when no status function is named, type coercion in
  ==), cover cancellation, run both sentinel steps against a failing stub,
  and pin Stage 12-only promotion and a lagging latest wrapper.
- Sentinel values that are booleans are refused and tested; the gateway
  skips blank or non-string model versions; bundle-versions passes the
  receipt certificate digest through unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Lock acquisition retries the atomic insert when the lock is absent and
  never deletes a lock it did not read as expired. An expired lock is
  reclaimed by a single waiter (an atomic reclaim token per stale owner),
  and a lock that changed hands is put straight back; release never
  removes someone else's lock. The lease is now 300 seconds.
- Each routing plan names its publication (run id, attempt and
  environment) in the published generation, so a later publication of
  the same release is a different state and an old plan cannot withdraw
  it.
- The automatic restore and the manual rollback wait 420 seconds for the
  lock, past its expiry, so a publisher that died holding it never blocks
  them.
- The manual rollback requires the plan of every attempt whose routing
  job kept one, and refuses an incomplete set instead of stopping short.
- Tests: the lock race, single-winner reclaim, release safety, a healthy
  republication, a held lock on apply, the restore step failing, the plan
  inventory, and the sentinel's tolerance boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four review rounds reproduced races in the routing write lock. modal.Dict
offers insert-if-absent but no compare-and-delete, so no lock on it is
both safe and live: an expired lock cannot be reclaimed safely, and an
unreclaimable one blocks restores after a writer dies.

Every workflow that writes routing state already runs in the
repository-wide concurrency group simulation-deploy-main with
cancel-in-progress false, which runs one at a time across workflows. So:

- The lock, reclaim tokens, TTL and wait constants and
  --lock-wait-seconds are gone.
- The CLI refuses any routing write unless it runs in GitHub Actions in
  PolicyEngine/policyengine-sim-api from simulation-deploy.yml,
  stage12-deploy.yml or simulation-rollback.yml (GITHUB_WORKFLOW_REF
  names the caller of a reusable workflow). Reads, plans and dry runs
  stay local.
- Invariant tests: every writer, direct or through the reusable
  workflow, declares that exact concurrency group; the guard's list
  equals the writers and the group's members; writers that share a Modal
  environment are ordered (stage12's parallel jobs write staging and
  main).
- Round-4 Lows: real jq selectors, sentinel default dependencies and
  both read-backs are tested. 14 of 14 mutants are killed.

Co-Authored-By: GPT-6.1 Sol via Subfleet <noreply@openai.com>
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.

1 participant