Repository navigation
Conversation
anth-volk
force-pushed
the
v2plan/1f-116-catalog-routes-on-demand
branch
from
October 26, 2026 16:51
bbad69e to
576b9b1
Compare
The metadata routers now use CacheableMetadataRoute, which gives every successful response a strong ETag (a hash of its exact body, so any content or format change gives a new one) and Cache-Control public: a day when the request names its catalog version, which never changes, and five minutes for the running version, which changes on deploy. A matching If-None-Match (strong or weak, alone or in a list) gets 304 with no body. Failures carry neither header, and the bodies are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /v2/model-catalog returns one catalog version's model and model version, entities, basic inputs, every region and logical input dataset, and the economy choices, in three queries and without reading variables, parameters or their values. A catalog without regions, such as a household-only country's, gets empty region, dataset and choice lists rather than an error; a version published before catalog format 2 has a null catalog_format and empty entities and basic inputs. The result names the selected PolicyEngine.py version so a client can ask for the other parts by that exact version. The query uses the shared CatalogQuery model, the route joins the metadata route group and the app's metadata workflow contract, and the migration contract documents are regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alues GET /v2/model-catalog/variables returns all of a catalog version's variables in name order, in one response, and adds the catalog format 2 fields (input and hidden-input flags, module, adds and subtracts parameter paths, and enum values with their labels) to every variable response, including /v2/variables pages. part_parameter_values maps each parameter path a variable's adds or subtracts names to that parameter's current-law values in start-date order, read in one query (none when no variable names one), so a client picks the list for the year it shows; a path the version lacks maps to an empty list. Times are served in UTC whatever the database session's time zone, so a response's bytes and ETag do not depend on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /v2/model-catalog/parameters takes up to 100 repeated name keys (the shared CatalogParameterQuery) and returns those parameters in name order, each with its economy and household flags and its current-law values by date, the stored labels of the parameter nodes above them (a bracket index such as rates[0] counting as a child of its schedule rates), and the requested names the version lacks, in four queries on the existing (version, name) unique indexes. A client showing or editing a reform loads all of its parameters in this one request. The economy and household flags also join every parameter summary, so /v2/parameters pages and details carry them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /v2/parameters accepts repeated name keys and GET /v2/parameter-values repeated parameter_name keys (at most 100 each), so a client can page only those parameters or their values. Because their query contracts change, both routes now read their query through the shared ParameterCollectionQuery and ParameterValueCollectionQuery models: unknown and repeated scalar parameters, an unsupported country_id and a malformed policyengine_version are rejected with 422 before any read (the last two returned 400 from the read before, and unknown parameters were ignored). The service and connector take the search and the names as one ParameterFilter, like ParameterValueFilter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured against the full 6.10.0 catalog in a local database, every new query uses an existing (version, name) or (version, code) unique index and takes under 4 ms; the cost of /v2/model-catalog/variables (about 160 ms, 3.9 MB, 0.58 MB compressed) is reading 6,167 rows and building their models. A published catalog version never changes, so each process now keeps the serialized bodies of /v2/model-catalog and /v2/model-catalog/variables once built, in a bounded store (eight route, country and requested-version combinations, least recently used dropped first) shared by every request of the default dependencies. Concurrent first requests wait for one read, and failed reads are not kept. A kept variables response takes about 25 ms, most of it compression. Tests pass their own store or none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…imes The release workflow's live metadata test, run against each staging and production Cloud Run candidate, now loads each country's catalog in parts: /v2/model-catalog for the running version, its variables for that exact version twice and once with If-None-Match (which must get 304), and one parameter by name. It appends each request's time and body size to the GitHub job summary (GITHUB_STEP_SUMMARY, set by the runner in every step), so every release records the routes' times against its own catalog. The new paths join its OpenAPI check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /v2/parameter-values?current=true returns the values in effect on the request's UTC day, but its response got the same lifetime as any other: a day when it named a version. A cache that fetched it late on 31 December would have served last year's values for up to a day. The route now marks such a request in request.state, and the caching route caps its lifetime at the seconds left until the next UTC midnight, whichever lifetime would otherwise apply. No other metadata route depends on the time of the request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the production engine options and a warm pool, the database's statement log showed five statements for each metadata request: the pool's liveness check, BEGIN, the catalog selection, the read and ROLLBACK. Each metadata read is one statement on rows a published catalog version never changes, so it needs no transaction. The default metadata reader now opens its session from a process-owned read-only factory bound to the v2 engine in autocommit mode (sharing its pool), so psycopg sends no BEGIN and no ROLLBACK: three statements per request. Other v2 sessions keep the ordinary mode, which the pool restores when a connection returns. The pool's liveness check is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After the autocommit change, each metadata request still sent three statements: the pool's liveness check, the catalog selection and the read. A published catalog version never changes, so the default metadata reader now keeps every successful selection in a process-wide SelectedCatalogStore, keyed by country and requested version, and later requests skip the selection query: two statements per request in the local statement log. Unsupported countries, invalid versions and absent catalogs are looked up, and fail, each time. Tests pass their own store or none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d from The response cache kept only bodies, so the caching route hashed the 3.9 MB US variables body again on every request, and a request without a version and one naming the running version kept two copies. Each kept response now carries its ETag, computed once when it is kept, which the caching route reuses, and responses are kept under the exact version they were read from: a request without a version records which version the process runs, so it and an explicit request for that version share one response. The route helper is generic over the result type, so mypy checks each operation's result against its response model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A malformed policyengine_version returned 422 from the routes that already used the shared query models but 400 from /v2/variables, /v2/regions, /v2/datasets, the model routes, the detail routes, the parameter tree and /v2/economy-options. The API routes guidance makes the shared definitions own each parameter's validation, so every metadata route now reads its query through them (CatalogQuery, CatalogVersionQuery for the route with the country in its path, CatalogPageQuery and resource models for variables, regions and the parameter tree), with the typed dependencies defined once. Unknown and repeated scalar parameters, a missing or unsupported country_id and a malformed version are rejected with 422 before any read on every route. The release check and the migration document now expect 422. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The current-law value and node-label connectors returned list[Any]; they now return CurrentLawValueRow and ParameterNodeLabelRow named tuples, so mypy checks every use of their fields. get_model_catalog re-implemented get_economy_options's national-region and default-dataset lookup; both now share _validated_national_dataset with a national_region transformation, and differ only in how they load the dataset. The part parameter paths and the found parameter names are computed once in the service and passed to the transformations instead of being recomputed there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CORS middleware exposed only the request and observability IDs and WWW-Authenticate, so a script on another origin could not read the ETag of a v2 metadata response to revalidate it. ETag joins Access-Control-Expose-Headers (Cache-Control is readable without it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…failure The release live test now also times a page of 500 parameters and one parameter's values, the two routes measured slow in production on 2026-10-21, and writes every time it reached to the job summary from a finally block, so a release whose check fails still records its measurements. The second variables request is labelled as possibly served by another instance, since its time need not be a kept response's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parameter-values route marked a current=true request as depending on the day but passed no instant to the read: the service took the day from its own clock during the read, and the lifetime came from another clock reading after it. A read just before UTC midnight whose headers were written just after got the old day's values with a lifetime of nearly a day. The route now takes the request's instant once, passes it to the read as the filter's now, and records it; the caching route gives the response the seconds left until the UTC midnight after that instant, measured when the headers are written, and zero once that midnight has passed. No other metadata read depends on the time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API reflects every origin in Access-Control-Allow-Origin, and the CORS middleware passes a request without Origin straight through, so such a response carried neither that header nor Vary: Origin while marked public for up to a day. A shared cache storing it could serve it to a later cross-origin request, which the browser rejects. Every cacheable metadata response, and every 304, now carries Vary: Origin, added to any existing Vary value such as compression's Accept-Encoding rather than replacing it. Failed reads are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The metadata routes' OpenAPI entries described 400 as an invalid PolicyEngine.py version, which now returns 422; 400 remains only for an unsupported country in the path of the by-country route. They also declared no 304 and none of the caching headers, so the app's generated client and readers could not see them. Every metadata route now documents ETag, Cache-Control and Vary on its 200 response and a 304 without a body. The statement counts in the changelog and the migration document now say exactly which requests send two statements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s size MetadataResponseCache.get_or_build removed the per-key build lock before storing the built response, so a request arriving in that interval found neither and started a second read. The lock is now released only after the response is stored (or the read failed). The concurrency tests no longer depend on timing: one blocks the build on an event, the other starts a second request while the first stores its result. The documentation states the bound's cost: the 6.10.0 US variables body is 3,910,063 bytes uncompressed, so eight entries hold about 11 MB in the expected mix and at most about 31 MB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The autocommit read sessions were described as read-only, but nothing stopped a flush. Autocommit rules out a per-transaction read-only setting, and a connection-level default would carry over to writers through the shared pool, so the factory now refuses every ORM flush with ReadOnlySessionError, and the docstring says exactly that only the metadata reader opens these sessions and that statements passed to execute are not checked. A test pins that the metadata reader factory is the only user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ters The layout guide listed neither the metadata route package's query_dependencies and response_cache modules nor the service package's selected_catalogs store; it now describes each and where process-wide state belongs. FoundParameters held ORM rows and a derivable name set in the framework-independent types module; it is removed. The service passes the parameters, their values grouped by name (through the now public dated_values_by_parameter_name transformation) and the missing names to the transformation, which no longer recomputes any of them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/v2/parameters/children built each leaf's parameter summary by hand from a few selected columns and left economy and household out, so a leaf always reported null, which the shared schema reserves for versions published before catalog format 2. The tree read now selects the stored Parameter, and each leaf is built with metadata_parameter, the same builder the parameter routes use, so the two can no longer differ. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MetadataResponseCache held a build lock for the whole read, and when the read failed each waiting request took the lock in turn and read again: ten concurrent first requests with a failing read ran ten reads one after another. A waiting request's cleanup could also delete a newer read's lock. Each key now has at most one read in progress; requests arriving meanwhile wait for it and receive its outcome, the kept response or their own copy of its error response (middleware adds request-specific headers, so error responses are not shared). Nothing is kept on failure, so the first request after it reads again, and a read removes only its own entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
with_cache_headers gave a 304 only Vary: Origin, while gzip added Accept-Encoding only to compressed 200 responses; a 304 must carry the Vary its 200 would. Every cacheable metadata response and its 304 now name Origin and Accept-Encoding. Starlette's CORS and gzip middleware append to Vary without checking for values already present, which gave "Origin" twice for cross-origin requests, so a small UniqueVaryMiddleware now wraps the CORS middleware and lists each Vary value once. Tested through the whole middleware stack, with and without Origin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every metadata route's OpenAPI entry listed 400 for an unsupported
country in the path, but only /v2/tax-benefit-models/by-country/
{country_id} takes the country in its path; on every other route the
shared query models reject an invalid country, version or page with 422
before any read, so 400 cannot occur there. The description now sits
only on that route.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The autocommit test used a NullPool engine, so it could not notice a pooled connection left in autocommit mode for the next writer. It now uses a pooled engine with one connection and checks that the writer that takes it next is transactional (with autocommit left on, the test fails). A new test runs the production combination of read sessions, SelectedCatalogStore and the response cache through the routes and counts the statements each request sends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fragment did not mention Vary, the readable ETag or how requests waiting on a whole-version read are answered, and described the current-value lifetime loosely. It now states each as the code does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ngth Two imports in the typed-rows test named the connector functions by long dotted paths and suppressed E501. The test now imports the two connector modules and calls the functions through them, so it needs no suppression. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app's v3 household builder and breakdown need each variable's unit (currency formatting) and definition period (per-month or per-year labels and household validation), which catalog format 2 now stores. Every variable response, in /v2/model-catalog/variables and in /v2/variables pages, now carries unit and definition_period, declared in OpenAPI and null for versions published before catalog format 2. The 6.10.0 US variables body grows from 3,910,063 to 4,190,824 bytes uncompressed (0.589 MB compressed, from 0.583 MB), and the documented bound of the response store is updated to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The format 2 fields on MetadataVariable (input and hidden-input flags, module, the two parameter paths, enum values, unit and definition period) and on MetadataParameterSummary (economy and household) defaulted to None, so the OpenAPI schema left them out of required although every response includes them, and a generated client would type them as possibly missing. They are now required and nullable; every constructor already passes them, and the route test fixtures now do too. A test checks the OpenAPI required lists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_uk_model_version duplicated _us_model_version except for the model name. Both are replaced by _model_version(session, model_name, version), which the tests call with the model they need. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Through the routes, with a response store: /v2/model-catalog/variables (read, then served from the stored body) and /v2/variables pages carry unit and definition_period for the US, for a UK variable with its own values, and as null keys for a version whose catalog_format is null. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… period One paragraph said the household builder uses the two fields for formatting and per-period labels, while the guide's table says the builder and the breakdown read them. The paragraph now says the builder and breakdown read them, for currency formatting, per-period labels and household validation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
force-pushed
the
v2plan/1f-116-catalog-routes-on-demand
branch
from
October 26, 2026 17:54
576b9b1 to
3885872
Compare
This branch was successfully 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 #4041
Summary
The v2 metadata routes now serve one catalog version in parts that a browser can load on demand and cache. Before, they served paged collections only, and v3 downloads the whole
/{country}/metadataresponse at start. v3 does not call these routes until it switches to v2.Routes and their shapes
All responses use the usual
{status, message, result}envelope.resultGET /v2/model-catalogcountry_id, optionalpolicyengine_versionpolicyengine_version,catalog_format,model,model_version(withcurrent_law_id,metadata_time_periods),entities(key,plural,is_person),basic_inputs,regions,datasets,economy_options(region,time_period,datasets)GET /v2/model-catalog/variablespolicyengine_version,catalog_format,variables,part_parameter_valuesGET /v2/model-catalog/parametersname(1 to 100)policyengine_version,catalog_format,parameters,node_labels,missing_namesGET /v2/parametersname(up to 100)economy,householdGET /v2/parameter-valuesparameter_name(up to 100)/v2/model-catalog: names the selected version, so a client that starts without a version learns the running one and requests every other part for that exact version. A catalog without regions (a household-only country) gets emptyregions,datasetsandeconomy_optionslists, not an error. A version published before catalog format 2 has a nullcatalog_formatand emptyentitiesandbasic_inputs.is_input_variable,hidden_input,module_name,adds_parameter_path,subtracts_parameter_path,enum_values,unitanddefinition_period)./v2/variablespages carry the same fields.unit(for examplecurrency-USD) anddefinition_period(year,monthoreternity) are there because v3's household builder and breakdown need them for currency formatting, per-month or per-year labels and household validation; they are null for versions published before catalog format 2.part_parameter_valuesmaps each parameter path a variable'saddsorsubtractsnames to that parameter's current-law values (start_date,end_date,value), so a client picks the list for the year it shows; a path the version lacks maps to an empty list.economy,householdand its current-lawvalues.node_labelsmaps each ancestor node to its stored label or null; a bracket index such asrates[0]counts as a child ofrates.missing_nameslists requested names the version lacks, so a reform saved against an older version still opens. Parameter-tree leaves (/v2/parameters/children) now carry the same summary, flags included.Caching
ETag(a hash of its exact body). A request whoseIf-None-Matchnames it (strong or weak form, alone or in a list) gets 304 with no body. Browser scripts can read theETag(Access-Control-Expose-Headers).Cache-Control: public, max-age=86400when the request namespolicyengine_version, andmax-age=300for the running version, which changes on deploy./v2/parameter-values?current=truetakes the request time once. The read uses that UTC day, and the response may be reused only until the UTC midnight after it (max-age=0if the headers are written after that midnight).Vary: every cacheable 200 and its 304 carryVary: Origin, Accept-Encoding, each value once. Starlette's CORS and gzip middleware append toVarywithout checking for values already present, so a smallUniqueVaryMiddlewarenow wraps the CORS middleware.ETag,Cache-ControlandVaryheaders. 400 is declared only on/v2/tax-benefit-models/by-country/{country_id}, the one route with the country in its path.MetadataResponseCachekeeps the serialized/v2/model-catalogand/v2/model-catalog/variablesbodies with their ETags, under the exact version they were read from. A running-version request and an explicit request for that version share one entry.SelectedCatalogStorekeeps each catalog version a request has selected. Failed selections are not kept.Contract change: 422 on every metadata route
Every v2 metadata route now reads its query through the shared query models in
policyengine_api.query_parameters(CatalogQuery,CatalogVersionQuery,CatalogPageQueryand resource models built on them), asdocs/engineering/skills/api-routes.mdrequires. Unknown parameters, repeated scalar parameters, a missing or unsupportedcountry_id, a malformedpolicyengine_versionand out-of-range pages return 422 before any read. Before, the older routes ignored unknown parameters and returned 400 for an unsupported country or malformed version. The release live test and the Stage 9 guide now expect 422. Only an unsupported country in the path of/v2/tax-benefit-models/by-country/{country_id}still returns 400.Measured locally (full 6.10.0 catalog, local Postgres)
/v2/model-catalog/v2/model-catalog/variables/v2/model-catalog/variables, UK/v2/model-catalog/parameters, 20 names/v2/parameters, page of 500The US variables payload is 0.59 MB compressed, above the plan's estimate of about 0.4 MB. Adding
unitanddefinition_periodgrew it from 3.91 MB to 4.19 MB uncompressed but only from 0.583 MB to 0.589 MB compressed.Latency: what was and was not shown
BEGIN, the catalog selection, the read andROLLBACK.pool_pre_ping) is unchanged.SPM availability
Not served. Nothing stores it yet; a later item records it per catalog version at publication, and
/v2/model-catalogserves it then.Migration notes
metadata(addsmodel-catalog). The contract registry gains the three new routes, and the generated migration contract documents are refreshed.GET /{country}/metadatais unchanged..added,.changedand.fixed.# noqa: E501on thereads_parameter_nodesimport inservices/v2/metadata/services.py. It is a dotted module path that cannot be wrapped, and it matches the existingreads_parameter_treeimport in the same module.Environment variables
None new.
V2_RUNTIME_DATABASE_URL_SECRET_RESOURCE(GitHub Actions secret → jobenv:inpush.yml→deploy_cloud_run_candidate.sh→ Cloud Run, resolved through Google Secret Manager).V2_SUPABASE_PROJECT_REFandV2_SUPABASE_ENVIRONMENT(GitHub Actions variables, same wiring).GITHUB_STEP_SUMMARY(set by the GitHub runner in every step) andAPI_BASE_URL(from the candidate step's output).What this waits for
Testing
CI on these branches is cancelled at the user's request. The evidence is local, red-green per commit: a failing run before each change, then a passing run.
Latest updates.
unitanddefinition_periodin catalog format 2. A commit now serves both fields; the review of that commit found no substantiated bugs.7e0fb9518andf803106ee), with no conflicts. Four commits follow:MetadataVariableandMetadataParameterSummaryare required (nullable) in the OpenAPI schema, so a generated client does not type them as possibly missing. Before the change, the new test failed: the schema'srequiredlist lacked the format 2 fields. After it, 1,233 v2 unit tests passed./v2/model-catalog/variables(read, then served from the stored body) and/v2/variablespages carryunitanddefinition_periodfor the US, a UK variable with its own values, and a version whosecatalog_formatis null. These tests pin existing behavior.On the final tip, with the locked .py 6.10.0 and the test schema recreated at head:
make testset without coverage: 3,619 passed;.coverageor names a private repository.Three review rounds ran:
current=truevalues.ETag, and live test timings.Vary: Originwithout anOriginheader.Accept-EncodinginVary.🤖 Generated with Claude Code