Repository navigation
Conversation
…s on A report call's invocation context can now carry the IDs of the Public API's report run and its baseline and reform simulation runs. Comparison runs leave the field unset, and a shadow run compared with a production call cannot carry it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The v2 Modal application now attaches the Modal secret policyengine-modal-worker-database, which must hold MODAL_WORKER_DATABASE_URL, to the report coordinator and the US and UK simulation workers. Region-group segments and worker validation do not receive it. An operator creates the secret before this deploys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Modal pre-empts a call with SIGINT, raised as KeyboardInterrupt, and cancels one with InputCancellation. Both are BaseExceptions that are not Exceptions, and Modal does not cancel the calls a stopped call spawned. The segmented US worker caught only Exceptions, so its region-group segment calls kept running after Modal stopped it. It now cancels every started segment call, then re-raises. Each started call is also recorded inside its dispatch span, so an interruption as the span closes still finds it. No database row or stored file changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A content-addressed store writes a simulation's planned input, its Parquet output and the report's aggregate under keys that end in the content's SHA-256. A call that Modal restarts after pre-emption can calculate different last bits, or plan its input on a redeployed copy of the application; its artifacts are then stored beside the earlier attempt's instead of failing the write-once check, and identical bytes reuse the earlier object. Comparison runs keep their fixed keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Stage 12 persistence library can now update the Public API's report_runs and simulation_runs rows: claim a report run under a Modal call (a pending one, or a running one no other call holds, which also lets a call Modal restarts resume its claim), mark it succeeded or failed only while that call holds it, claim a simulation run, record its output file as it succeeds, and fail a pending or running one. Each write is one conditional UPDATE and returns whether it changed the run and the run's status, so a repeated write changes nothing that is final. It also checks that a report run combines the simulation runs a call names. The mappings declare only the columns these writes touch. The tests run against a disposable Postgres migrated by the Public API's v2 chain, as the Modal worker role, and skip when no test database is configured. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a worker's invocation context names a report run, the worker keeps its Stage 12 child record as before and also records its own simulation run, as the Modal worker role: it claims the run under its Modal call ID after claiming the child record, records the output file and marks the run succeeded before marking the child record succeeded, and marks the run failed after a recorded failure. A wrapper around the comparison store makes these writes, so the coordinator's own failure and cancellation records reach the simulation runs too. A simulation run that is already failed refuses the claim, which the worker treats as a cancellation. Success comes first so a worker that Modal restarts between the two writes finds its child record still running and runs again, and its second output never replaces the first. Such a worker names its artifacts by their digest. The US and UK worker functions now pass their Modal call ID; comparison runs record nothing new. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the coordinator's invocation context names a report run, it first checks that the run combines the call's baseline and reform simulation runs and claims it under its own Modal call ID. A run another call holds, or one already finished, makes the call return a deduplicated summary before it starts anything. The coordinator marks the report run succeeded before it marks its Stage 12 report succeeded, and marks it failed, with the redacted failure, whenever it records a Stage 12 failure. Its failure and cancellation records of child simulations also reach their simulation runs, and it names its aggregate by its digest. Comparison runs record nothing new, and their children receive exactly the context fields they received before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Modal pre-empts a call with SIGINT, which the coordinator receives as KeyboardInterrupt, and later runs the same call again under the same call ID; it cancels a call with InputCancellation. Neither is an Exception. A coordinator whose call names a report run now cancels its started, unfinished child calls on either and re-raises, recording no failure. When Modal restarts it, it resumes the Stage 12 claim it holds under that call ID instead of treating the report as a duplicate, cancels the child calls its earlier attempt started, and starts the children again; the report run is claimed again by the same call and marked succeeded once. A restart after the report run succeeded starts nothing. Comparison runs keep their behaviour. Time limits are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The report run store can now write a report's single-year economy output in the same transaction that marks the report run succeeded, only when that status write changes the run. Budget totals, income deciles and intra-decile shares become rows keyed by the report run and its two simulation runs; poverty rates and inequality become rows keyed by each simulation run, under .py's poverty types and income variables for the country; the labour supply response and cliff impact are stored whole on the report run. A repeated completion stores nothing, a failed insert leaves the run running, and a simulation run another report run already stored a row for keeps that row. The geographic impacts, the detailed budget, the UK's wealth deciles, the SPM provenance and the data version have no lossless place in the result tables yet and are not written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A coordinator whose call names a report run now passes the economy output of its aggregate to the report run store, which writes it as result rows in the transaction that marks the run succeeded. An output the store cannot hold fails the report, as any other failure does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Public API's result tables now have a place for the parts of the economy output that had none. A report run's result also writes the detailed budget to program_statistics, one row per program with a null entity and the output's is_tax flag; congressional district impacts with their winner, loser and no-change shares, keyed by the Census state FIPS code and district number, an at-large district being 0, from .py's US state data; UK constituency and local authority impacts with their disclosure tier and interval bounds; and the SPM provenance and data version on the report run. A district in a state .py doesn't name is refused. The UK's wealth deciles still have no place and aren't written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The result tables follow .py's row models. decile_impacts.relative_change holds .py's percentage, while the economy output holds a fraction, so the writer now multiplies it by 100 and a reader divides it again, equal to within floating-point rounding. intra_decile_impacts row 0 is .py's share across the whole population, while the output's overall shares are the mean of the decile shares; the writer no longer puts the mean in row 0, and a reader computes it again from deciles 1 to 10, exactly as the output does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The coordinator now marks its Stage 12 report succeeded or failed first, and its report run second; each worker marks its Stage 12 child record succeeded first, and its simulation run second. A call Modal restarts between the two writes therefore finds the Stage 12 record finished. A coordinator restarted after its Stage 12 report succeeded under the same call ID reads the stored aggregate, checks its digest, and completes the report run from it without calculating again; one restarted after a failure runs the report again. A worker restarted after its child record succeeded completes its simulation run from that record, so both name the same output file. Before, a restart after the report run succeeded left the Stage 12 report running for good. The claims now live in their own module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once a coordinator holds its report run, an error claiming its Stage 12 report, or completing the run from a stored aggregate, now fails the report run before the error propagates, instead of leaving it running. Modal cancels a call with InputCancellation and never runs it again, so a coordinator whose call is cancelled now records its unfinished children, its Stage 12 report and its report run as failed, with the code report_cancelled; pre-emption, KeyboardInterrupt, still records nothing, because Modal restarts that call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The persistence library's README and the database-access skill now describe the Public API's run tables that the Modal worker role writes, the store's module docstring says that a completion also inserts its result rows, and the secret's module says that the secret must exist in a Modal environment before the v2 application deploys there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In .py's poverty rows, filter_variable names the model variable a group is filtered on (age, sex or race), with its bounds and group label in columns the table doesn't have, so a group's rate has no column that keeps .py's meaning, and the three age groups would collide on the table's unique key as age. The writer now stores only each simulation run's poverty and deep poverty rate for everyone, with a null filter variable, instead of putting group labels in filter_variable. A UK report no longer writes a household_state_income_tax budget row: the UK model has no such variable, and today's output reports 0.0, which a reader gives again when the row is absent. The area mapping is typed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A worker writes its Stage 12 child record first and its simulation run second, so a lost second write left the simulation run behind: a failure the worker or coordinator could not mirror stayed running, and so did a success when the worker stopped between the writes and a later call reused the child. Before it completes or fails a report run, the coordinator now gives each of the report's two simulation runs its child record's outcome, with the same conditional writes, so a report run never succeeds beside a simulation run still running. A worker whose second write fails after its child record succeeded logs it and returns its result instead of failing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The coordinator claims its report run, then its Stage 12 report. A cancellation between the two, or while it completes the run from a stored aggregate on a restart, now fails the report run with the code report_cancelled before it propagates; Modal never runs a cancelled call again. A pre-emption there still records nothing, because Modal runs the call again and it resumes its claim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The coordinator test runs both children through run_single_simulation, sharing the coordinator's Stage 12 store and report run store, and checks that the report run and both simulation runs are recorded once, each simulation run under its own worker's call and naming its child record's output file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A coordinator restarted after a pre-emption starts its children again; if it failed, or was cancelled, before it had, the children its earlier attempt claimed stayed running under calls that attempt had cancelled. A failed report run's coordinator now fails each child record of the report still running, which also fails its simulation run. A cancellation during the report run claim's commit, or while the coordinator records a failure, now still fails the report run. A restarted worker whose completion of its simulation run fails logs it and returns its result, as a worker does after a fresh success, since the coordinator completes the run from the child's record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 tasks done
The Public API's run tables changed under this work (PolicyEngine/policyengine-api#4162): the labour supply response, cliff impact, SPM receipts and data version moved from `report_runs` to a `report_run_comparisons` row per report run and compared pair, and the result tables gained the keys that hold the parts left out before. - A report run's completion inserts its `report_run_comparisons` row with its other result rows, in the same transaction as its success. - UK wealth deciles are stored in `decile_impacts`, with `decile_variable` `household_wealth_decile`; income deciles leave it null. - Poverty rates by age, gender and race are stored with .py's groups: `filter_variable` names the variable that selects a group (`age`, `is_male`, `race`) and `filter_group` the group; both stay null for everyone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each poverty rate's measure, selecting variable, group and values are now a named tuple, with every group listed under the variable that selects it. The rows' docstring says that today's output reports 0.0 for a rate .py didn't report and for a breakdown it failed to calculate. The UK test also checks the income deciles' relative change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The US and UK single-simulation functions in the Stage 12 Modal app now pass `modal.current_function_call_id()` to the runtime, so a call that names a report run can record its simulation run under its own call ID. The tests load the app against a stand-in `modal` module, which has no `current_function_call_id`, and the `_invoke` helper set one only for the coordinator. The US and UK cases of the four tests that call each Stage 12 function through `_invoke` therefore raised `AttributeError` before reaching the code they test. `_invoke` now gives the stand-in a call ID for the workers too, unless the test already set its own, so the test that checks a worker passes its own call ID still sees its own value. Production code is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The simulation service now records the Modal call it starts on the report run while the run is still pending. A claim therefore accepts a pending or running report run only while it records no call or the claiming call's own; a run recording another call belongs to that call, and the coordinator returns the run's status without starting anything, as it already does for a run another call holds. Simulation runs keep moving to the newest call: the coordinator starts their calls, and records nothing on them while they are pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `ReportRunWriter` protocol still said any pending report run is claimed, and a store test was named for the simulation service marking a run running, which it no longer does. The protocol now states the store's rule, and the test is named for what it checks: a running report run with no call recorded is claimed and keeps its first start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PolicyEngine/policyengine-api#4162's rebuilt schema changes three things this branch writes: - A report run names its two simulation runs, in `baseline_simulation_run_id` and `reform_simulation_run_id`, and `report_run_simulation_runs` is gone. `require_simulation_runs` reads those two columns: a report run that names other simulation runs raises ValueError, and a missing one LookupError. - `report_run_comparisons` is gone. The cliff impact is four columns of the report run, the SPM receipts its `jsonb` column and the data version its own column; the completion's `UPDATE` sets them with the run's success, so they roll back with it. The labour supply response is one `labor_supply_responses` row and one `labor_supply_decile_responses` row per decile, inserted with the other result rows. .py reports relative responses only for deciles above zero, so a decile without one has null relative columns. - The foreign keys between the two services' tables stay, so the tests' simulation runs name a real catalog version and dataset, and are inserted before the report run that names them. The completion's `UPDATE` sets only columns the Modal worker role may update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…losely - The Modal worker role is refused an `UPDATE` of what a run is (a report run's baseline simulation run, a simulation run's year) and of a report run's markdown, so a database where it still holds `UPDATE` on whole tables fails the store tests. - The economy output's labour supply earnings changes and weekly hours now have different values, so a mapping that swapped one with the other fails. - An absent SPM receipt is checked to be SQL NULL, not JSON null, which reads back as None too. - `require_simulation_runs` compares the report run's two simulation runs by name and says which runs the report run names. The cliff columns are built in one branch, and docstrings say a result is the report run's own columns as well as rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test of the Modal worker role's column grants now names each refused update as a case with functions that pick the row and the value from the seeded runs, instead of a placeholder string. The in-memory report run store's error message names the simulation runs, as the store's does. `database-access.md` no longer calls the Stage 12 comparison tables the only tables this repository uses: the Public API owns those and the run tables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The column-grant test now updates `updated_at` through the same statement first: the role may update it, so the refusal that follows is of the refused column, not of the row the statement selects. `database-access.md` lists the Stage 12 comparison tables and the run tables as the two sets the Public API owns, says deployment validation doesn't compare the run tables' mappings with the live schema, and says both sets stay with the Public API once this repository has its own migrations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
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 #991
Same-Repository Draft PR Check
PolicyEngine/policyengine-sim-api, not a personal fork.Summary
The v2 Modal report coordinator and its US and UK simulation workers can now record a report's run in the Public API's run tables, with the Modal worker role's own database credentials. When a call's invocation context names a report run and its baseline and reform simulation runs, the coordinator and workers write those runs' status, each simulation's output file and the report's economy results. The report run is marked succeeded in the same transaction as its result rows, so a call that Modal restarts after pre-emption cannot record its result twice. A call that names no report run behaves exactly as today. Nothing sends such a call yet: the simulation service's
/simulationsand/reportsroutes, a later change, will.How a call names its runs (
policyengine_simulation_contract)Stage12InvocationContext.report_run(new, optional): aReportRunIdswithreport_run_id,baseline_simulation_run_idandreform_simulation_run_id, three different IDs. A shadow run compared with a production call cannot carry it. Comparison runs leave it unset, and their child calls receive exactly the context fields they received before.Coexisting with the comparison path
The smallest change: a call that names a report run still runs through the Stage 12 comparison records (
stage12_evaluation_reports,stage12_evaluation_simulations) and the Stage 12 result bucket, which coordinate its workers as before, and also records the Public API's runs. Those writes go through a second store (ReportRunStore, inpolicyengine_stage12_persistence), connected as the Modal worker role.SELECTthat the report run names the call's two simulation runs as its baseline and reform, then claims the report run under its own Modal call ID, then its Stage 12 report. The claim takes a pending or running report run only while it records no Modal call or this call's own: the simulation service records the call it starts while the run is still pending, and Modal restarts a pre-empted call under the same ID. A report run that records another call, or that is already finished, makes the call return a deduplicated summary and start nothing. It marks its Stage 12 report succeeded or failed first and its report run second; the report run's success is written in one transaction with its result rows. Any failure, including one claiming the Stage 12 report, fails the report run with a redacted code and summary.SimulationRunRecordingStore) writes each Stage 12 child record first and then mirrors its claim, success and failure onto that child's simulation run: the worker claims its simulation run under its own Modal call ID, records the output file as the run succeeds, and a recorded failure, including the coordinator's cancellation of a child, fails the simulation run. A simulation run that is already failed refuses the worker's claim, which it treats as a cancellation.UPDATE … WHERE id = … AND status = …(and, for the report run,AND modal_call_id = …), returning whether it changed the run and the run's status, so a repeated write changes nothing that is final.Restarts and cancellation
Modal pre-empts a call with SIGINT, received as
KeyboardInterrupt, and runs it again under the same call ID; it cancels a call withInputCancellation, and never runs a cancelled call again. Neither is anException.report_cancelled), because nothing would finish them otherwise; so does one cancelled while it claims its Stage 12 report.inputs/<role>-<sha256>.json,simulations/<role>-<sha256>.parquet,reports/aggregate-<sha256>.json), so a restarted call that calculates different last bits stores them beside its earlier attempt's instead of failing the write-once check. Comparison runs keep their fixed keys.Results as rows
The report's result is the single-year economy output Stage 12 already builds, in the format the Public API and v3 read today. Each part becomes rows, with the meaning .py's row models give the result tables' columns, in the same transaction as the report run's success:
budget_summary(entityhousehold):household_tax,household_benefitsand, for the US only,household_state_income_tax(change),household_net_incomeandhousehold_weight(baseline total, the latter the household count); the budgetary impact is tax minus benefits, as the output computes it, and a missing state income tax row is 0.0, as the output reports for the UK.program_statistics: the detailed budget, one row per program, with a null entity (the output names none) and the output'sis_taxflag.decile_impacts: the change inhousehold_net_incomeby income decile, with a nulldecile_variable, and (UK) by wealth decile, withdecile_variablehousehold_wealth_decile, as .py names them; the relative change is .py's percentage, so the output's fraction is multiplied by 100 (a reader divides again, equal to within floating-point rounding).intra_decile_impacts: income and (UK) wealth deciles 1 to 10. Row 0 is .py's population-wide share, which the output doesn't carry: its overall shares are the mean of the deciles, which a reader computes again exactly.poverty, per simulation run: the poverty and deep poverty rates under .py's poverty types (spm,spm_deep;relative_bhc,absolute_bhc), for everyone and by .py's groups:filter_variablenames the variable that selects a group (age,is_male,race) andfilter_groupthe group (child,adult,senior;male,female;white,black,hispanic,other), both null for everyone. Race covers poverty only, and only in the US, as the output does. Today's output reports 0.0 for a rate .py didn't report and for every rate of a breakdown it failed to calculate, so a stored 0.0 may be such a rate.inequality, per simulation run, under .py's income variables. A simulation run that another report run already stored a row for keeps its row (ON CONFLICT DO NOTHING).congressional_district_impacts, with the winner, loser and no-change shares, keyed by the Census state FIPS code and district number (0 for an at-large state), from .py's US state data.constituency_impactsandlocal_authority_impacts, with their disclosure tier and interval bounds.labor_supply_responses: one row with the labour supply response's earnings changes from the income and substitution effects and their total, the revenue change, the two relative responses, and the baseline, reform and changed weekly hours with the change split into the two effects.labor_supply_decile_responses: one row per decile, with the income and substitution responses per household and relative to earnings; .py reports relative responses only for deciles above zero, so a decile without one has null relative columns. An output with no labour supply response stores neither.UPDATEthat marks it succeeded: the baseline's and reform's cliff gap and cliff share (four columns, all null when the output has no cliff impact), the SPM receipts (jsonb) and the data version.Nothing else in the output needs a column: a reader computes the budgetary impact and the overall intra-decile shares again from these rows, and
model_versionis the report run'scountry_package_version, which the simulation service sets when it creates the run.Tests
The store's tests run against a disposable Postgres migrated with the Public API's v2 chain from PolicyEngine/policyengine-api#4162, with that change's declared privileges applied by its own reconcile script, connected as the Modal worker role, whose
UPDATEcovers only its runs' status, call, error, timing and result columns. That change keeps the foreign keys between the two services' tables, so each test's runs name real catalog rows, simulations and a report. They skip whenREPORT_RUN_TEST_DATABASE_URLandREPORT_RUN_TEST_SEED_DATABASE_URLare unset, as they are in CI. They cover the conditional claims, including a pending report run that records another call, completions and failures; a result whose insert fails leaving the run running, with none of its rows and none of its own result columns; a repeated completion storing nothing; a shared simulation run keeping its rows; a round trip of each part of the output, including the UK wealth deciles, the poverty rates by group and the labour supply response by decile; an output without labour supply or cliff impact; a report run that names other simulation runs than its call; and the role being refused an insert intoreport_runs. The runtime tests use in-memory stand-ins for Modal and both stores, including coordinators pre-empted at each point between their writes and restarted, cancelled coordinators, restarted workers, lost second writes, and a report run through the real worker code.Environment variables
MODAL_WORKER_DATABASE_URL(new): read by the coordinator and the US and UK simulation workers, only for a call that names a report run. Provider: the Modal secretpolicyengine-modal-worker-database, which an operator creates in thestagingandmainModal environments (below). Wiring:modal.Secret.from_name(..., required_keys=["MODAL_WORKER_DATABASE_URL"])attached throughsecrets=tocoordinate_report,run_single_simulation_usandrun_single_simulation_ukinsrc/modal/v2_app.py. Not verified: the secret does not exist yet.STAGE12_DATABASE_URL,STAGE12_ARTIFACT_BUCKET(unchanged): the Modal secretstage12-evaluation-runtime, synced from Secret Manager by each deploy'sstage12-sync-modal-secrets.sh.REPORT_RUN_TEST_DATABASE_URL,REPORT_RUN_TEST_SEED_DATABASE_URL: test-only, set by hand for a local database; nothing sets them in CI.What this waits for before merging
UPDATE.policyengine_v2_modal_workercreated, with a password, in the staging and production v2 databases (an operator step), so the Public API's deploy grants it its privileges.policyengine-modal-worker-databasewith keyMODAL_WORKER_DATABASE_URLin thestagingandmainModal environments (an operator step): the URL of that role on that environment's v2 database. Merging deploys the v2 Modal apps with the secret attached, andmodal deployfails without it, so this comes before the merge.The Modal secret (operator steps, for each of
stagingandmain)Name
policyengine-modal-worker-database, keyMODAL_WORKER_DATABASE_URLand no other.Value: a
postgresql://URL that logs in aspolicyengine_v2_modal_workerto databasepostgresof that environment's v2 database, in the same format as the existingSTAGE12_DATABASE_URL:postgresql://policyengine_v2_modal_worker.<project-ref>:<password>@<pooler-host>:5432/postgres?sslmode=require. The scheme must bepostgresql://; the code selects the driver.Create it from
projects/policyengine-simulation-executor, without echoing the value:Check it without printing it:
modal secret list --env staginglists the name;psql "<url>" -Atc 'select current_user'(from a file holding only the URL) prints the role;select count(*) from public.report_runssucceeds andinsert into public.report_runs …is refused. The first deploy after merging must passmodal deploy, which checks the required key.🤖 Generated with Claude Code