Repository navigation
Calculate stored households as household jobs through /household-calculations - #1046
Merged
Merged
Conversation
The simulation service will read households, policies, parameters, parameter values and catalog versions. These tests require read-only models of those tables, apart from the run models, and compare each column they name with the Public API's own table: its type and whether it allows null. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Read-only models of households, policies, parameters, parameter values, tax-benefit models and catalog versions, in their own metadata, name only the columns the simulation service reads. The Public API schema job now also requires each of those columns to exist in the Public API's table with the same type and nullability, under rule sim.persistence.models-match-public-api. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stored policy value with no end date has to change every year a household job can be calculated for, up to 2200. In a 2200 calculation a range ending on 31 December 2200 applies in both countries, a range ending in 2100 leaves current law, and a start date alone is read as "from that day on" by policyengine-core (US) but as that single day by policyengine-uk's reform dictionary. The simulation service therefore writes an open-ended value as a range ending on 31 December 2200. This test documents the household functions' existing behaviour and changes no code, so it passed when first run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es (failing) A stored policy's parameter values become the reform values the household functions take, by parameter name and period. A value with an end date covers its start to its end; one without covers its start to 31 December 2200, the latest year a calculation can be for. The tests also require the policy to belong to the country, every parameter it sets to exist in the catalog version the calculation runs on, and no two values of a parameter to fall on one period. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
read_policy_reform returns a policy's parameter values by name and period, as the Public API's policy JSON writes them, after checking the policy belongs to the country and every parameter it sets exists in the catalog version the calculation runs on. A value without an end date is written as a range ending on 31 December 2200, so it changes every year a calculation can be for in both countries' models. It lives in the persistence library so the economy routes can use it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…(failing) The service finds the catalog version by country and .py version and the stored household in its country, creates the job pending with no Modal call ID, then records the call's ID, or fails the job when Modal refuses the call, each only while the job is still pending with no call ID. A job naming a row that does not exist is refused by its foreign key, and the refusal names the column. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The persistence library reads the stored household and the catalog version a household calculation needs, creates its household_jobs row pending with no Modal call ID and no dynamic, then records the call's ID or fails the job when Modal refuses the call. Both updates apply only while the job is still pending with no call ID, set only the columns the role may update, and set updated_at themselves. A foreign-key refusal names the job's column. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ents (failing) The store reads a household calculation's catalog version, household and policy together, and creates, records, fails and reads its job, each in a transaction of its own. Each transaction limits how long a statement may run, for that transaction alone, and rolls back if its block raises. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HouseholdJobStore reads a household calculation's catalog version, household and policy together, and creates, records, fails and reads its job, each in a transaction of its own, as the Simulation Entrypoint needs around the Modal call. bounded_transaction commits when its block ends and limits each statement with set_config, local to the transaction, as the readiness check already does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rsion (failing) The Simulation Entrypoint gets the v2 manifest's name and Modal environment as settings of its own, apart from the Stage 12 settings, and serves household calculations only with those and its database. It uses the manifest's default worker only, and says when that worker has no household function for the country or the manifest cannot be read. A stored household becomes the situation the household functions take, with its saved SPM selection kept apart (ported from sim #957). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd conversion SIMULATION_V2_MODAL_ENVIRONMENT and SIMULATION_V2_MANIFEST_NAME name the Modal environment that holds the v2 workers and their manifest, apart from the Stage 12 settings, whose resources stay all or nothing. With them and the database URL the service can serve household calculations. ManifestHouseholdWorkers finds the household function and bundle of the manifest's default worker only. household_situation, ported from sim #957, turns a stored household into the situation the household functions take, and refuses a document it cannot read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The service reads the default worker, the catalog version for its .py version, the stored household and the policy, then builds the household contract's request with the new job's ID as its calculation ID, and refuses before writing anything whatever is missing (400) or cannot be calculated (422), naming why. It creates the job, starts the household function's call with spawn and the job's ID, and records the call's ID; when Modal refuses the call, or does not accept it in time, the job fails at once. The household function is called by name in the v2 Modal environment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HouseholdCalculationService finds the v2 manifest's default worker's household function, reads the catalog version for its .py version, the stored household and the policy in one transaction, and builds the household contract's request for the household's default year with the new job's ID as its calculation ID. Whatever is missing is refused before anything is written, naming why; stored data that cannot be calculated is refused with one reason per field. It then creates the job, starts the call with spawn and the job's ID, and records the call's ID; a call Modal refuses, or does not accept in ten seconds, fails the job at once. The SPM settings are the request's, else the household's saved selection. How the request names the job's one policy is kept in household_contract_fields, so a change to the contract's policy fields changes that module alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing)
POST /household-calculations (simulation:submit) answers 202 with the
job's ID and status, 400 or 422 with the reason a submission is refused,
and 503 with Retry-After when the service is not configured or a
dependency cannot be read. GET /household-calculations/{id}
(simulation:read) answers the job's status, result document and error
message, or 404, and never invents an observability ID. The OpenAPI
document declares every answer, and the route table and the comparison
with the old gateway's contract include the two routes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The routes are registered by _register_household_calculation_routes in app.py, on a router of their own, with the simulation:submit and simulation:read permissions. A submission binds the caller's observability ID, or a new one, and passes it with the trace context to the household function; a read binds none, since household_jobs records none. Refusals answer 400 or 422 with their reasons, and an unconfigured service, an unreadable manifest or database, or a refused Modal call 503 with Retry-After. The OpenAPI document declares every answer. The service is built when the database URL and the v2 Modal environment are both set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nifest (failing) The household routes call the v2 workers' household functions, so the Cloud Run deploy must mount MODAL_TOKEN_ID and MODAL_TOKEN_SECRET, each once, and set SIMULATION_V2_MODAL_ENVIRONMENT and SIMULATION_V2_MANIFEST_NAME on every deploy, not only when it deploys Stage 12, and stop when a token's secret name is missing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A staging deploy, after the v2 manifest's default moves to the release's worker, calculates the stored staging household that the beta variable SIMULATION_STAGING_HOUSEHOLD_ID names, through the candidate's household routes with the test client's token, and requires the job to succeed within ten minutes. The deployment is complete only when the check passed in staging and was skipped elsewhere. No token or client secret reaches a command line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…heck a staging household The Cloud Run deploy now mounts MODAL_TOKEN_ID and MODAL_TOKEN_SECRET and sets SIMULATION_V2_MODAL_ENVIRONMENT and SIMULATION_V2_MANIFEST_NAME on every deploy, not only inside the DEPLOY_STAGE12_V2 block, which no longer mounts the token itself. Every staging deployment then calculates the stored staging household that the beta variable SIMULATION_STAGING_HOUSEHOLD_ID names, through the candidate, after the v2 default has moved, and deployment_ready requires that check to pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… into v2plan/c1-2-household-calculations Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… names it #903's contract takes the request's policy as one policy field with its parameters, and returns one calculated household. The service sends the job's policy there, with no policy key, and the 422 reasons trace the contract's policy field to the request's policy_id. The executor's reform period test calculates each period form in a request of its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The generated simulation client leaves an omitted spm out of a household request and keeps an explicit null, and reads a job's result document whole. The database-access skill names the Public API's input tables the service reads. Adds the changelog fragment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reads (failing) The Public API's household calculation client names the new job job_id, sends axes only when given, within the household contract's limits, and sends SPM settings only as an object. The tests require the request to accept axes and pass them into the household document, to refuse an explicit null or non-object spm, and both answers to name the job job_id, as must the staging check that reads them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The household request now takes axes, within the household contract's limits, and passes them into the household document; they are stored with the request. spm must be an object when given: omit it to use the household's saved selection. Both answers name the job job_id, which is what the Public API's client reads, and the staging check reads it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…2plan/c1-2-household-calculations Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.py's reform_dict_from_parameter_values stops a value without an end date the day before the same parameter's next later value starts, and otherwise runs it to 2100-12-31. The reader must do the same, but run the last one to 2200-12-31, the latest year a calculation can be for: a value followed by a bounded one then gives way to current law once the bounded one ends, and values without end dates run one after another. Two values of a parameter starting on the same day are refused. The executor tests pin .py's own periods for the same cases; they describe .py's existing behaviour, so they passed when first run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts, as .py does A stored value without an end date now stops the day before the same parameter's next later value starts, as .py's reform_dict_from_parameter_values does, and otherwise runs to the end of 2200 rather than .py's 2100. A value followed by a bounded one no longer applies again after the bounded one ends. Two values of one parameter starting on the same day are refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…usals (failing) When Modal accepts the household function's call after the ten-second wait and the function claims the job first, the failure write changes nothing: the answer must be 202 with the job as it stands, not a 503 saying it failed. When the failure write itself fails, or the job then can't be read, the answer is a 503 job_state_unknown naming the job, so the caller reads it instead of submitting again. The fakes now model the function's claim, a failing failure write and a late answer. A refused field the service builds itself, such as the bundle, is an internal error (500), and every other refused field traces to the request field that set it. A job read declares only the answers it gives, and both routes' 503s declare Retry-After. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l refusals When Modal doesn't confirm the household function's call within the wait, the service fails the job only while it is still pending with no call ID, and answers 503 modal_call_refused only when that write failed the job. When it changed nothing, the function claimed the job because Modal accepted the call after all, so the job is read and answered with 202 as it stands. When the failure write or that read fails, the answer is 503 job_state_unknown naming the job, to be read before submitting again. A contract refusal now traces each field to the request field that set it: the stored household and its year to household_id, the axes to axes, the policy to policy_id, the SPM settings to spm or the saved household. A refused field the service builds itself, such as the bundle, answers 500 internal_error. The job read declares only the answers it gives, and both routes' 503s declare Retry-After. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-calculations Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ay values (failing) Every 500 of the household routes, an unexpected exception included, must be the JSON HouseholdCalculationError with code internal_error, naming the error's type in the log but never its values, as the generated client expects for both routes. When Modal didn't confirm the call and the job's state can't be recorded or read back, the job exists, so the answer is 202 pending, read with GET, rather than a 503 that invites a second submission. Of a parameter's values starting on the same UTC day, the latest takes that day over; the executor test shows .py and policyengine-core calculating the same, so it passed when first run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and keep a day's latest value Both household routes now answer any unexpected exception with the JSON HouseholdCalculationError their OpenAPI answers declare, code internal_error, logging the route and the error's type only; the GET route declares its 500 too. No app-wide handler changes other routes. When Modal didn't confirm the call and the failure write, or reading the job back, fails, the job exists, so the answer is now 202 pending for the caller to read, not a 503 that invites a second submission; the stuck-run sweep fails a job no call ever started. The job_state_unknown code is gone. Of a parameter's values starting on the same UTC day, the policy reader keeps the latest, as .py's own periods amount to, instead of refusing the policy, and ConflictingPolicyValuesError is gone. The error model's household_job_id and the contract fields module are described as they now are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and the 500 log (failing) .py's reform_dict_from_parameter_values keeps both of two bounded values starting on the same day, in their order, so each holds part of the year; an open-ended value after a bounded one the same day takes over; and a start the Public API stored in UTC is read on its UTC day. The reader must give the same periods, with 2200 where .py has 2100, and leave out only an open-ended value whose range would end before it starts. The executor tests pin .py's own output and apply it with policyengine-core; they describe existing behaviour, so they passed when first run. The household routes' 500 log must be the fixed event household_route_failed with error_type, route and stack frames, and no exception message or values. The submission's 202 description must say a pending job may be one whose call Modal didn't confirm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n order The policy reader now gives each value the period .py's reform_dict_from_parameter_values gives it, in .py's order for values in start order, with 2200 where .py has 2100. Bounded values starting the same day are all kept, so policyengine-core applies each to part of the year as .py's dictionary does. Only an open-ended value whose range would end before it starts, because a later value starts the same day or because it starts after 2200, is left out; .py's range for it applies on no day. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…describe a pending 202 An unexpected failure in a household route is now logged as the fixed event household_route_failed with the route, error_type and the stack frames (file, line and function), never the exception's message or any value. The fields also go in policyengine_attributes, the only extra fields the observability runtime's log handler keeps. The submission's 202 description, the routes module, the submit docstring and the changelog fragment now say that a 202 pending may be a job whose call Modal didn't confirm, to be polled rather than submitted again, and list the 500. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es (failing) The test now reads the record the observability runtime prints, not the standard-library record, and a second test checks that the frames fit the runtime's string limit, keeping the innermost ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… log limit The observability runtime keeps only strings, numbers and booleans as attributes, so the list of frames never reached the emitted record. The frames are now one string, innermost first, holding as many as fit the runtime's string limit, with the number left out as `stack_frames_omitted`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g over the same day The period tests now read a stored policy through `read_policy_reform` instead of calling `reform_period`, and a new reader test covers an open-ended value followed the same day by a bounded one. The executor pins .py's dict for that case: policyengine-core applies the bounded value and then current law, and policyengine-uk's reform dictionary reader refuses .py's range that ends before it starts. These describe existing behaviour, so they passed when first run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… refuses .py's empty range `reform_period` was exported but used only by tests, and returned a range ending before it starts for a value starting after 2200, which policyengine-uk refuses. The reader now builds each period with one private function that returns None for such a value, and the library no longer exports `reform_period`. The module docstring and the readme now say that .py's range ending before it starts makes a UK calculation fail, where policyengine-core applies it on no day. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ibrary's (failing) A failure raised deep in a library, which the persistence library called, both at the image's site-packages paths, must log the frame that raised it, the persistence call and the route's handler, with each path starting after site-packages/ or the checkout's src/. The fake can now raise its failure through a given function. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each frame's path now starts after site-packages/ (the image) or the checkout's src/, so more frames fit the log's string limit. When they still don't all fit, the frame that raised the error is kept, then the frames of policyengine_simulation_entry and policyengine_simulation_persistence, then other libraries' inner frames. The kept frames stay innermost first, and stack_frames_omitted counts every frame left out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test_py_reform_dicts docstring said "starting after the stop", which could mean .py's 2100 or the reader's 2200. The reader leaves out a last open-ended value starting in 2201 or later, and gives one starting in 2101 to 2200 a range to the end of 2200, where .py's range for it ends before it starts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Nov 11, 2026
…ease environment The new staging_household_check job declares the release environment, because it reads the beta variable SIMULATION_STAGING_HOUSEHOLD_ID and the SIMULATION_ENTRYPOINT_AUTH_* and SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_* secrets, which exist only as beta and prod environment secrets. That raised the number of jobs declaring it from 17 to 18, which the deployment-assets test still expected to be 17. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…when they were added Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r simulation and Modal script The executor's test_all_scripts_have_shebang requires every .github/scripts/modal-*.sh and simulation-*.sh script to start with #!/bin/bash; the two new staging household scripts started with #!/usr/bin/env bash. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1045
Summary
Adds
POST /household-calculationsandGET /household-calculations/{household_job_id}to the Simulation Entrypoint. They create and read household jobs, rows of the Public API'shousehold_jobstable, and start the v2 Modal household functions (#903), which calculate one household for one year under one policy and record the result on the job. Replaces #957, which was built on a schema that is no longer used.POST(permissionsimulation:submit) takescountry,household_id,policy_id(omitted or null for current law),add_missing_variables, optionalaxesand optionalspmsettings. In order, it:default_year, withcalculation_idset to the new job's ID;pending, with no Modal call ID,dynamic_idnull andrequest_dataholding the request as received;spawn, passing the job's ID, and waits up to 10 seconds for Modal to accept it;pendingwith none, and answers 202{job_id, status}.If Modal refuses the call, the job is failed at once under the same condition, with
error_messageandcompleted_at, and the answer is 503modal_call_refused. If Modal accepts it late and the function has already claimed the job, the answer is 202 with the job's current status. If the job's state can't be recorded or read back, the answer is 202pending: poll the job rather than submitting again. The stuck-run sweep fails a job no call ever started after about 10 minutes. Each submission creates a new job.GET(permissionsimulation:read) answers 200 withjob_id,status,country,household_id,policy_id,year, both package versions,result(the household function's result or failure document),error_messageand the timestamps; 404unknown_household_job.Error answers
{code, detail}default_yearor one that can't be read; a policy parameter missing from the catalog version; an explicit nullspm; a request the household contract refuses, traced to the request field that set it. FastAPI'sdetailfield list.internal_errorhousehold_route_failedwith the route, the error's type and its stack frames as one string, innermost first, within the observability runtime's string length limit (stack_frames_omittedcounts the frames left out). It never carries the error's message or any value.{code, detail},Retry-After: 10not_configured,manifest_unavailable,database_unavailable,modal_call_refused(withhousehold_job_id).Policy periods. The policy reader in
libs/policyengine-simulation-persistence(policy_reform.py, reusable by the economy routes) gives each stored parameter value the period .py'sreform_dict_from_parameter_valuesgives it, in the same order, with one difference: the last open-ended value stops at 2200-12-31, where .py stops at 2100-12-31, because households can be calculated up to 2200. An open-ended value that .py would give a range ending before it starts (one followed the same day by another value, or one starting after 2200) is left out. policyengine-core applies such a range on no day, but policyengine-uk refuses it with "ends before it starts", so leaving it out also keeps those UK calculations from failing.Also:
DEPLOY_STAGE12_V2;betavariableSIMULATION_STAGING_HOUSEHOLD_IDnames;Operator steps before merging (done 2026-11-11)
Without them, every staging deploy fails at the new household check, and production doesn't deploy. Both were done and read back on 2026-11-11:
POST /v2/households?country_id=us, which answered 201 with IDb6604e4a-a531-404a-b839-42a3f78fc873. The row is deduplicated by content, so a repeat returns the same ID.betaenvironment variableSIMULATION_STAGING_HOUSEHOLD_IDis set to that ID.The staging catalog holds the default worker's .py version, 6.17.1 (checked through
GET /v2/model-catalog?country_id=us&policyengine_version=6.17.1), so the check won't fail withunknown_catalog_version. Not verified: that staging sim's database URL names the same v2 database as the staging Public API; the first staging deploy's check confirms it.The Public API's matching routes, PolicyEngine/policyengine-api#4274, merge after this is on staging.
Tests
Each test was run individually by node ID, and seen failing for the expected reason before its change, except the executor tests that pin .py's existing behaviour, which passed on their first run.
Persistence library, on PostgreSQL 17 built from the Public API's
masterwith its privileges applied and verified: the policy reader, the household job records and store, the read-only input models and their comparison, and the bounded transactions.Simulation Entrypoint: the service, routes, contract-field tracing, workers, household conversion, deploy wiring, the staging check, authorization and the OpenAPI document. The 500 log tests read the record the service's own observability runtime emits.
Executor: reform periods applied in 2200 with the real US and UK models, and .py's own
reform_dict_from_parameter_valueson same-day, bounded and open-ended values, applied with policyengine-core, including the case policyengine-uk refuses.Generated client: submit and read, including the JSON 500.
Checks: CI's Ruff, the rules registry, the quality guards including file length, the lock checks and Pyright passed before each commit.
CI fixes:
test_deployment_assets.py::test_deployment_uses_gcloud_workflow_without_terraformcounts the reusable deploy workflow's jobs that declareenvironment: ${{ inputs.release_environment }}. The staging household check is one of them, because it reads that environment's secrets and variable, so the expected count is now 18, not 17.#!/bin/bash, which the executor'stest_modal_scripts.py::TestAllScriptsHaveShebang::test_all_scripts_have_shebangrequires of everymodal-*.shandsimulation-*.shscript.Review: three rounds, and four more of the fix code.
All are fixed test-first, with each round's code-quality findings.
Environment variables and secrets
MODAL_TOKEN_ID,MODAL_TOKEN_SECRETbeta/prodvariablesMODAL_TOKEN_ID_SECRET_NAME,MODAL_TOKEN_SECRET_SECRET_NAMEenv:(jobenvironment: inputs.release_environment), then the deploy script's--set-secretswith the newest version. Now mounted on every deploy; the Stage 12 block no longer mounts them separately.SIMULATION_V2_MODAL_ENVIRONMENTmodal_environmentSIMULATION_V2_MANIFEST_NAMESIMULATION_SERVICE_DATABASE_URL/readyalready checks every grant these routes use.SIMULATION_STAGING_HOUSEHOLD_IDbetaenvironment variable (not set yet)STAGING_HOUSEHOLD_ID.SIMULATION_ENTRYPOINT_AUTH_ISSUER,_AUDIENCE,SIMULATION_ENTRYPOINT_TEST_AUTH_CLIENT_ID,_SECRETbetaandprodenvironment secrets, with no repository-level copiesenvironment: inputs.release_environment(betaon staging), then its step'senv:. The callers'secrets: inheritpasses only repository and organization secrets, so the job'senvironment:is what makes these available. They exist;authenticated_testalready reads them.Not verified:
simulation:submitandsimulation:read(this matters once enforcement is on);🤖 Generated with Claude Code