From 280ba71e087c7e636d19bcf9f52d80cbffdd7601 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:55:10 +0400 Subject: [PATCH 01/28] Generate a typed Public API v2 client with an advisory drift check The app gains types for the Public API's v2 routes, generated with openapi-typescript from a checked-in copy of its OpenAPI document (app/src/api/publicApi/openapi.json), and a small openapi-fetch client that sends the existing v2 credentials and unwraps the success envelope. - `bun run public-api:generate` writes schema.d.ts from openapi.json. - `bun run public-api:refresh ` copies the document from an API origin, a URL or a file, then regenerates. - `bun run public-api:check` fails when schema.d.ts does not match the document; the new PR job "Public API client is current" runs it. The app has no required checks, so it stays advisory. Nothing calls the client yet. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr.yaml | 20 + app/package.json | 5 + app/scripts/public-api-client.ts | 144 + app/src/api/publicApi/client.ts | 82 + app/src/api/publicApi/openapi.json | 14347 ++++++++++++++++ app/src/api/publicApi/schema.d.ts | 8655 ++++++++++ app/src/api/v2UserClient.ts | 16 +- .../fixtures/scripts/publicApiClientMocks.ts | 46 + .../tests/unit/api/publicApi/client.test.ts | 135 + .../unit/scripts/public-api-client.test.ts | 96 + bun.lock | 54 +- calculator-app/package.json | 1 + 12 files changed, 23589 insertions(+), 12 deletions(-) create mode 100644 app/scripts/public-api-client.ts create mode 100644 app/src/api/publicApi/client.ts create mode 100644 app/src/api/publicApi/openapi.json create mode 100644 app/src/api/publicApi/schema.d.ts create mode 100644 app/src/tests/fixtures/scripts/publicApiClientMocks.ts create mode 100644 app/src/tests/unit/api/publicApi/client.test.ts create mode 100644 app/src/tests/unit/scripts/public-api-client.test.ts diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index f29d3cb57..b0b63005c 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -112,6 +112,26 @@ jobs: working-directory: ./website run: bun run test + public-api-client: + name: Public API client is current + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + # Regenerates the types from the checked-in OpenAPI document and fails + # when src/api/publicApi/schema.d.ts differs from the result. + - name: Check the generated client against its OpenAPI document + working-directory: ./app + run: bun run public-api:check + check-embeds: name: Check embedded site URLs runs-on: ubuntu-latest diff --git a/app/package.json b/app/package.json index b7472eb0d..a561d535e 100644 --- a/app/package.json +++ b/app/package.json @@ -7,6 +7,9 @@ "evaluate-search": "tsx scripts/evaluate-parameter-search.ts", "refresh-search-eval-cases": "tsx scripts/refresh-parameter-search-cases.ts", "build-parameter-dependencies": "python3 scripts/build-parameter-dependency-map.py", + "public-api:generate": "tsx scripts/public-api-client.ts generate", + "public-api:check": "tsx scripts/public-api-client.ts check", + "public-api:refresh": "tsx scripts/public-api-client.ts refresh", "typecheck": "tsc --noEmit", "lint": "bun run eslint && bun run stylelint", "eslint": "eslint . --cache --max-warnings 0", @@ -39,6 +42,7 @@ "fuse.js": "^7.0.0", "html-to-image": "^1.11.13", "lucide-react": "^0.575.0", + "openapi-fetch": "0.17.0", "policyengine-household-wizard": "^0.1.0", "radix-ui": "^1.4.3", "react": "^19.1.0", @@ -78,6 +82,7 @@ "fast-check": "^4.10.2", "identity-obj-proxy": "^3.0.0", "jsdom": "^26.1.0", + "openapi-typescript": "7.13.0", "postcss": "^8.5.5", "postcss-simple-vars": "^7.0.1", "prettier": "^3.5.3", diff --git a/app/scripts/public-api-client.ts b/app/scripts/public-api-client.ts new file mode 100644 index 000000000..270e906bf --- /dev/null +++ b/app/scripts/public-api-client.ts @@ -0,0 +1,144 @@ +/** + * TypeScript types for the Public API's v2 routes (policyengine-api), + * generated from its OpenAPI document (`GET /v2/openapi.json`). + * + * The document is checked in beside the generated types, so a build never + * calls the API, and CI can check that the types match the document: + * + * bun run public-api:generate writes schema.d.ts from openapi.json + * bun run public-api:check fails when schema.d.ts is out of date + * bun run public-api:refresh copies the document from a URL or a + * file, then writes schema.d.ts + * + * is an API origin (https://api.policyengine.org), a full URL of + * the document, or a path to a saved copy of it. + */ +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import openapiTS, { astToString, type OpenAPI3 } from 'openapi-typescript'; +import prettier from 'prettier'; + +const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); +const CLIENT_DIRECTORY = path.resolve(SCRIPT_DIRECTORY, '../src/api/publicApi'); + +export const OPENAPI_DOCUMENT_PATH = path.join(CLIENT_DIRECTORY, 'openapi.json'); +export const GENERATED_SCHEMA_PATH = path.join(CLIENT_DIRECTORY, 'schema.d.ts'); + +const GENERATED_HEADER = `/** + * Generated from src/api/publicApi/openapi.json by scripts/public-api-client.ts. + * Do not edit: run \`bun run public-api:generate\` (or \`public-api:refresh\`). + */ +`; + +/** The path the API serves its v2 OpenAPI document at. */ +const OPENAPI_DOCUMENT_ROUTE = '/v2/openapi.json'; + +/** The generated, formatted types for an OpenAPI document. */ +export async function generatePublicApiTypes(openapiDocument: OpenAPI3): Promise { + const syntaxTree = await openapiTS(openapiDocument); + const source = `${GENERATED_HEADER}\n${astToString(syntaxTree)}`; + const prettierConfig = (await prettier.resolveConfig(GENERATED_SCHEMA_PATH)) ?? {}; + return prettier.format(source, { ...prettierConfig, filepath: GENERATED_SCHEMA_PATH }); +} + +export interface SchemaComparison { + upToDate: boolean; + expected: string; +} + +/** Whether checked-in types equal what the document generates. */ +export async function compareGeneratedTypes( + openapiDocument: OpenAPI3, + checkedInTypes: string +): Promise { + const expected = await generatePublicApiTypes(openapiDocument); + return { upToDate: expected === checkedInTypes, expected }; +} + +/** Where to read the document from: an origin gets the v2 route appended. */ +export function openapiDocumentLocation(source: string): string { + if (!/^https?:\/\//.test(source)) { + return source; + } + const url = new URL(source); + if (url.pathname === '/' || url.pathname === '') { + url.pathname = OPENAPI_DOCUMENT_ROUTE; + } + return url.toString(); +} + +/** The document as checked in: two-space JSON with a final newline. */ +export function serializeOpenapiDocument(openapiDocument: OpenAPI3): string { + return `${JSON.stringify(openapiDocument, null, 2)}\n`; +} + +async function readOpenapiDocument(location: string): Promise { + if (/^https?:\/\//.test(location)) { + const response = await fetch(location, { headers: { Accept: 'application/json' } }); + if (!response.ok) { + throw new Error(`GET ${location} returned ${response.status}`); + } + return (await response.json()) as OpenAPI3; + } + return JSON.parse(await readFile(location, 'utf8')) as OpenAPI3; +} + +async function writeGeneratedTypes(): Promise { + const openapiDocument = await readOpenapiDocument(OPENAPI_DOCUMENT_PATH); + await writeFile(GENERATED_SCHEMA_PATH, await generatePublicApiTypes(openapiDocument)); + console.info(`Wrote ${path.relative(process.cwd(), GENERATED_SCHEMA_PATH)}`); +} + +async function checkGeneratedTypes(): Promise { + const openapiDocument = await readOpenapiDocument(OPENAPI_DOCUMENT_PATH); + const checkedInTypes = await readFile(GENERATED_SCHEMA_PATH, 'utf8'); + const { upToDate } = await compareGeneratedTypes(openapiDocument, checkedInTypes); + if (upToDate) { + console.info('The generated Public API types match openapi.json.'); + return true; + } + console.error( + 'src/api/publicApi/schema.d.ts does not match src/api/publicApi/openapi.json.\n' + + 'Run `bun run public-api:generate` in app/ and commit the result.' + ); + return false; +} + +async function refreshOpenapiDocument(source: string | undefined): Promise { + if (!source) { + throw new Error('Name the document to copy: an API origin, a URL or a file path.'); + } + const location = openapiDocumentLocation(source); + const openapiDocument = await readOpenapiDocument(location); + await writeFile(OPENAPI_DOCUMENT_PATH, serializeOpenapiDocument(openapiDocument)); + console.info(`Copied ${location} to ${path.relative(process.cwd(), OPENAPI_DOCUMENT_PATH)}`); + await writeGeneratedTypes(); +} + +async function main(commandLineArguments: string[]): Promise { + const [command, source] = commandLineArguments; + switch (command) { + case 'generate': + await writeGeneratedTypes(); + return 0; + case 'check': + return (await checkGeneratedTypes()) ? 0 : 1; + case 'refresh': + await refreshOpenapiDocument(source); + return 0; + default: + console.error('Usage: public-api-client.ts generate | check | refresh '); + return 2; + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)).then( + (exitCode) => process.exit(exitCode), + (error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } + ); +} diff --git a/app/src/api/publicApi/client.ts b/app/src/api/publicApi/client.ts new file mode 100644 index 000000000..3f3bcad47 --- /dev/null +++ b/app/src/api/publicApi/client.ts @@ -0,0 +1,82 @@ +import createClient, { type Client } from 'openapi-fetch'; +import { + credentialHeaders, + failureMessage, + V2ApiError, + type V2Credentials, +} from '@/api/v2UserClient'; +import { BASE_URL } from '@/constants'; +import type { paths } from './schema'; + +/** + * A typed client for the Public API's v2 routes (policyengine-api), generated + * from its OpenAPI document (see scripts/public-api-client.ts). Route paths, + * query parameters, bodies and responses are checked against + * src/api/publicApi/schema.d.ts at compile time. + */ +export type PublicApiClient = Client; + +export interface CreatePublicApiClientOptions { + /** Who requests are made as; metadata routes need no one. */ + credentials?: V2Credentials; + baseUrl?: string; + fetchImpl?: typeof fetch; +} + +export function createPublicApiClient({ + credentials = { kind: 'none' }, + baseUrl, + fetchImpl, +}: CreatePublicApiClientOptions = {}): PublicApiClient { + const client = createClient({ + baseUrl: baseUrl ?? BASE_URL, + // Resolved per request, so a stubbed global fetch is honoured. + fetch: (request: Request) => (fetchImpl ?? globalThis.fetch)(request), + }); + client.use({ + async onRequest({ request }) { + for (const [name, value] of Object.entries(await credentialHeaders(credentials))) { + request.headers.set(name, value); + } + return request; + }, + }); + return client; +} + +interface ClientOutcome { + data?: Data; + error?: unknown; + response: Response; +} + +/** + * The `result` of a v2 success envelope (`{status: "ok", message, result}`). + * A failed response throws V2ApiError with its status and the API's message, + * and a request that never reached the API throws status 0. + */ +export async function requireResult( + call: Promise> +): Promise { + let outcome: ClientOutcome<{ result: Result }>; + try { + outcome = await call; + } catch (error) { + if (error instanceof V2ApiError) { + throw error; + } + throw new V2ApiError( + 0, + error instanceof Error ? `Network error: ${error.message}` : 'Network error' + ); + } + const { data, error, response } = outcome; + if (!response.ok || data === undefined) { + const apiMessage = + error && typeof error === 'object' && 'message' in error + ? String((error as { message: unknown }).message) + : null; + throw new V2ApiError(response.status, failureMessage(response.status, apiMessage)); + } + return data.result; +} diff --git a/app/src/api/publicApi/openapi.json b/app/src/api/publicApi/openapi.json new file mode 100644 index 000000000..9008b2d4a --- /dev/null +++ b/app/src/api/publicApi/openapi.json @@ -0,0 +1,14347 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "PolicyEngine API", + "version": "3.38.1" + }, + "paths": { + "/v2/me": { + "get": { + "summary": "Read the current user", + "description": "For a bearer access token, returns the PolicyEngine user, creating it on the first call with primary_country set from country_id. For the PolicyEngine-Unauthenticated-User header, returns the browser's unauthenticated user, or 404 before its first saved record; reading never creates one.", + "operationId": "get_me_v2_me_get", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeDetailResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + }, + "patch": { + "summary": "Update the signed-in user's names or primary country", + "description": "Unauthenticated users cannot edit account details (403).", + "operationId": "patch_me_v2_me_patch", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountPatchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeDetailResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + }, + "delete": { + "summary": "Delete the current user and all of their saved records", + "description": "For a signed-in user, obtains Auth0 Management credentials first when they are configured, then deletes the user's legacy user mappings, the user (identities and associations cascade), and the Auth0 user. For an unauthenticated user, deletes the user and its associations and never calls Auth0; 204 even when it does not exist.", + "operationId": "delete_me_v2_me_delete", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "502": { + "description": "Account data was deleted but the Auth0 user could not be removed; retrying is safe.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "Auth0 Management credentials could not be obtained, or persistence is unavailable; nothing was deleted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/me/export": { + "get": { + "summary": "Export the current user and every saved record", + "description": "Covers every country, not only country_id. user is null, and every list empty, for an unauthenticated user with no saved records.", + "operationId": "export_me_v2_me_export_get", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeExportResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/me/upgrade": { + "post": { + "summary": "Move this browser's unauthenticated user into the signed-in account", + "description": "Requires both a bearer access token and the PolicyEngine-Unauthenticated-User header. When the signed-in identity has no account yet, the unauthenticated user becomes it in place, keeping its ID and records (converted). When the identity already has an account, every record moves to that account with its ID unchanged and the unauthenticated user is deleted (merged). When the header names no unauthenticated user, including a signed-in account's ID, nothing moves (none). The response is the signed-in account.", + "operationId": "upgrade_me_v2_me_upgrade_post", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "PolicyEngine-Unauthenticated-User", + "in": "header", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + "description": "The browser's unauthenticated user ID (a canonical UUID).", + "title": "Policyengine-Unauthenticated-User" + }, + "description": "The browser's unauthenticated user ID (a canonical UUID)." + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeUpgradeResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/user-reports": { + "post": { + "summary": "Save a v1 report for the current user", + "description": "Requires a bearer access token or the PolicyEngine-Unauthenticated-User header; the owner is taken from that credential. Stage 12A accepts legacy_report_id only (the requested v1 report output ID), checked in the v1 database. An optional user_policy_id must name the same user's saved policy in the same country.", + "operationId": "create_user_report_v2_user_reports_post", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserReportCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserReportDetailResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + }, + "get": { + "summary": "List the current user's saved reports", + "operationId": "get_user_reports_v2_user_reports_get", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "description": "Zero-based result offset", + "default": 0, + "title": "Offset" + }, + "description": "Zero-based result offset" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "description": "Maximum number of returned resources", + "default": 100, + "title": "Limit" + }, + "description": "Maximum number of returned resources" + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "legacy_report_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "maximum": 9223372036854776000, + "minimum": 1, + "description": "Exact country-scoped v1 database identifier" + }, + { + "type": "null" + } + ], + "title": "Legacy Report Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserReportPageResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/user-reports/{association_id}": { + "get": { + "summary": "Read one of the current user's saved reports", + "operationId": "get_user_report_v2_user_reports__association_id__get", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "association_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Association Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserReportDetailResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + }, + "patch": { + "summary": "Update or repoint one of the current user's saved reports", + "operationId": "patch_user_report_route_v2_user_reports__association_id__patch", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "association_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Association Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserReportPatchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserReportDetailResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + }, + "delete": { + "summary": "Delete one of the current user's saved reports", + "operationId": "delete_user_report_route_v2_user_reports__association_id__delete", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "association_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Association Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/shared-reports/{association_id}": { + "get": { + "summary": "Read a saved report by its shareable link", + "description": "Needs no credential: a saved report's ID is its link, and anyone holding it can read the report. The owner and the owner's saved-reform IDs are left out; the reform the report was compared with is named instead.", + "operationId": "get_shared_report_v2_shared_reports__association_id__get", + "parameters": [ + { + "name": "association_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Association Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedUserReportDetailResponse" + } + } + } + }, + "404": { + "description": "No saved report has this ID in this country.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The ID or the query does not match the schema.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/user-simulations": { + "post": { + "summary": "Save a v1 simulation for the current user", + "description": "Requires a bearer access token or the PolicyEngine-Unauthenticated-User header; the owner is taken from that credential. Stage 12A accepts legacy_simulation_id only, checked in the v1 database.", + "operationId": "create_user_simulation_v2_user_simulations_post", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSimulationCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSimulationDetailResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + }, + "get": { + "summary": "List the current user's saved simulations", + "operationId": "get_user_simulations_v2_user_simulations_get", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "description": "Zero-based result offset", + "default": 0, + "title": "Offset" + }, + "description": "Zero-based result offset" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "description": "Maximum number of returned resources", + "default": 100, + "title": "Limit" + }, + "description": "Maximum number of returned resources" + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "legacy_simulation_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "maximum": 9223372036854776000, + "minimum": 1, + "description": "Exact country-scoped v1 database identifier" + }, + { + "type": "null" + } + ], + "title": "Legacy Simulation Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSimulationPageResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/user-simulations/{association_id}": { + "get": { + "summary": "Read one of the current user's saved simulations", + "operationId": "get_user_simulation_v2_user_simulations__association_id__get", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "association_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Association Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSimulationDetailResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + }, + "patch": { + "summary": "Update or repoint one of the current user's saved simulations", + "operationId": "patch_user_simulation_route_v2_user_simulations__association_id__patch", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "association_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Association Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSimulationPatchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSimulationDetailResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + }, + "delete": { + "summary": "Delete one of the current user's saved simulations", + "operationId": "delete_user_simulation_route_v2_user_simulations__association_id__delete", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "association_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Association Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/user-households": { + "post": { + "summary": "Save a household for the current user", + "description": "Requires a bearer access token or the PolicyEngine-Unauthenticated-User header; the owner is taken from that credential. Accepts legacy_household_id (a v1 household, checked in the v1 database) or household_id (a v2 household).", + "operationId": "create_user_household_v2_user_households_post", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserHouseholdCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserHouseholdDetailResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + }, + "get": { + "summary": "List the current user's saved households", + "operationId": "get_user_households_v2_user_households_get", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "description": "Zero-based result offset", + "default": 0, + "title": "Offset" + }, + "description": "Zero-based result offset" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "description": "Maximum number of returned resources", + "default": 100, + "title": "Limit" + }, + "description": "Maximum number of returned resources" + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "household_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "description": "Exact resource UUID" + }, + { + "type": "null" + } + ], + "title": "Household Id" + } + }, + { + "name": "legacy_household_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "maximum": 9223372036854776000, + "minimum": 1, + "description": "Exact country-scoped v1 database identifier" + }, + { + "type": "null" + } + ], + "title": "Legacy Household Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserHouseholdPageResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/user-households/{association_id}": { + "get": { + "summary": "Read one of the current user's saved households", + "operationId": "get_user_household_v2_user_households__association_id__get", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "association_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Association Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserHouseholdDetailResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + }, + "patch": { + "summary": "Update or repoint one of the current user's saved households", + "description": "The row keeps its id, owner and creation time. Changing legacy_household_id repoints the row and re-resolves household_id from the Stage 11 copy mapping, or clears it. Changing household_id alone points the row at that v2 household, which must exist in the same country, and clears legacy_household_id. Supplied together, both are stored, and household_id must be the copy of legacy_household_id when one exists.", + "operationId": "patch_user_household_route_v2_user_households__association_id__patch", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "association_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Association Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserHouseholdPatchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserHouseholdDetailResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + }, + "delete": { + "summary": "Delete one of the current user's saved households", + "operationId": "delete_user_household_route_v2_user_households__association_id__delete", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "association_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Association Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/user-policies": { + "post": { + "summary": "Save a policy for the current user", + "description": "Requires a bearer access token or the PolicyEngine-Unauthenticated-User header; the owner is taken from that credential. Accepts legacy_policy_id (a v1 policy, checked in the v1 database) or policy_id (a v2 policy). When only legacy_policy_id is supplied, policy_id is filled from the Stage 10 mirror mapping when one exists.", + "operationId": "create_user_policy_v2_user_policies_post", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPolicyCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPolicyDetailResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + }, + "get": { + "summary": "List the current user's saved policies", + "operationId": "get_user_policies_v2_user_policies_get", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "description": "Zero-based result offset", + "default": 0, + "title": "Offset" + }, + "description": "Zero-based result offset" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "description": "Maximum number of returned resources", + "default": 100, + "title": "Limit" + }, + "description": "Maximum number of returned resources" + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policy_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "description": "Exact resource UUID" + }, + { + "type": "null" + } + ], + "title": "Policy Id" + } + }, + { + "name": "legacy_policy_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "maximum": 9223372036854776000, + "minimum": 1, + "description": "Exact country-scoped v1 database identifier" + }, + { + "type": "null" + } + ], + "title": "Legacy Policy Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPolicyPageResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/user-policies/{association_id}": { + "get": { + "summary": "Read one of the current user's saved policies", + "operationId": "get_user_policy_v2_user_policies__association_id__get", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "association_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Association Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPolicyDetailResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + }, + "patch": { + "summary": "Update or repoint one of the current user's saved policies", + "description": "The row keeps its id, owner and creation time. Changing legacy_policy_id repoints the row and re-resolves policy_id from the Stage 10 mirror mapping, or clears it. Changing policy_id alone points the row at that v2 policy, which must exist in the same country, and clears legacy_policy_id. Supplied together, both are stored, and policy_id must be the mirror copy of legacy_policy_id when one exists. Rows maintained by the v1 saved-policy mirror return 409.", + "operationId": "patch_user_policy_route_v2_user_policies__association_id__patch", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "association_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Association Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPolicyPatchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserPolicyDetailResponse" + } + } + } + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + }, + "delete": { + "summary": "Delete one of the current user's saved policies", + "operationId": "delete_user_policy_route_v2_user_policies__association_id__delete", + "security": [ + { + "UserAccessToken": [] + }, + { + "UnauthenticatedUser": [] + } + ], + "parameters": [ + { + "name": "association_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Association Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "description": "The supplied references or countries contradict each other.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "401": { + "description": "A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "403": { + "description": "Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The record does not exist or belongs to another user.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The request body exceeds 256 KiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the schema, or a referenced v1 artifact or linked record does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The operation could not be completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "User authentication or persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/households": { + "post": { + "summary": "Create or find an immutable household", + "operationId": "create_household_v2_households_post", + "parameters": [ + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HouseholdCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Equivalent immutable content already exists.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HouseholdDetailResponse" + } + } + } + }, + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HouseholdDetailResponse" + } + } + } + }, + "400": { + "description": "The household content or country selection is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The selected household does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "Immutable household content conflicts with stored state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The household request body exceeds 1 MiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the household schema.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "Stored household integrity validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "Supabase household persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + }, + "get": { + "summary": "List immutable households", + "operationId": "get_households_v2_households_get", + "parameters": [ + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "description": "Zero-based result offset", + "default": 0, + "title": "Offset" + }, + "description": "Zero-based result offset" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "description": "Maximum number of returned resources", + "default": 100, + "title": "Limit" + }, + "description": "Maximum number of returned resources" + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "default_year", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "maximum": 2200, + "minimum": 1900, + "description": "Default year for values without explicit period keys" + }, + { + "type": "null" + } + ], + "title": "Default Year" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HouseholdPageResponse" + } + } + } + }, + "400": { + "description": "The household content or country selection is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The selected household does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "Immutable household content conflicts with stored state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The household request body exceeds 1 MiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the household schema.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "Stored household integrity validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "Supabase household persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/households/{household_id}": { + "get": { + "summary": "Read one immutable household", + "operationId": "get_household_v2_households__household_id__get", + "parameters": [ + { + "name": "household_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Household Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HouseholdDetailResponse" + } + } + } + }, + "400": { + "description": "The household content or country selection is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The selected household does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "Immutable household content conflicts with stored state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The household request body exceeds 1 MiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the household schema.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "Stored household integrity validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "Supabase household persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/policies": { + "post": { + "summary": "Create or find an immutable policy", + "operationId": "create_policy_v2_policies_post", + "parameters": [ + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Equivalent immutable content already exists.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyDetailResponse" + } + } + } + }, + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyDetailResponse" + } + } + } + }, + "400": { + "description": "The policy content or country selection is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The selected policy or catalog does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "Immutable policy identity conflicts with stored state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The policy request body exceeds 1 MiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the policy schema.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "Stored policy integrity validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "Supabase policy persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + }, + "get": { + "summary": "List immutable policies", + "operationId": "get_policies_v2_policies_get", + "parameters": [ + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "description": "Zero-based result offset", + "default": 0, + "title": "Offset" + }, + "description": "Zero-based result offset" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "description": "Maximum number of returned resources", + "default": 100, + "title": "Limit" + }, + "description": "Maximum number of returned resources" + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "tax_benefit_model_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "description": "Exact resource UUID" + }, + { + "type": "null" + } + ], + "title": "Tax Benefit Model Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyPageResponse" + } + } + } + }, + "400": { + "description": "The policy content or country selection is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The selected policy or catalog does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "Immutable policy identity conflicts with stored state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The policy request body exceeds 1 MiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the policy schema.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "Stored policy integrity validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "Supabase policy persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/policies/{policy_id}": { + "get": { + "summary": "Read one immutable policy", + "operationId": "get_policy_v2_policies__policy_id__get", + "parameters": [ + { + "name": "policy_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Policy Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyDetailResponse" + } + } + } + }, + "400": { + "description": "The policy content or country selection is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The selected policy or catalog does not exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "409": { + "description": "Immutable policy identity conflicts with stored state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "413": { + "description": "The policy request body exceeds 1 MiB.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request does not match the policy schema.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "Stored policy integrity validation failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "Supabase policy persistence is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/tax-benefit-models": { + "get": { + "summary": "List models for one PolicyEngine.py catalog", + "operationId": "list_models_v2_tax_benefit_models_get", + "parameters": [ + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "description": "Zero-based result offset", + "default": 0, + "title": "Offset" + }, + "description": "Zero-based result offset" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "description": "Maximum number of returned resources", + "default": 100, + "title": "Limit" + }, + "description": "Maximum number of returned resources" + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataModelPageResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/tax-benefit-models/by-country/{country_id}": { + "get": { + "summary": "Get a country model and selected PolicyEngine.py version", + "operationId": "get_model_by_country_v2_tax_benefit_models_by_country__country_id__get", + "parameters": [ + { + "name": "country_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Country Id" + } + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataModelSelectionResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/tax-benefit-models/{model_id}": { + "get": { + "summary": "Get one model from a selected PolicyEngine.py catalog", + "operationId": "get_model_v2_tax_benefit_models__model_id__get", + "parameters": [ + { + "name": "model_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Model Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataModelDetailResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/tax-benefit-model-versions": { + "get": { + "summary": "List selected PolicyEngine.py model versions", + "operationId": "list_model_versions_v2_tax_benefit_model_versions_get", + "parameters": [ + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "description": "Zero-based result offset", + "default": 0, + "title": "Offset" + }, + "description": "Zero-based result offset" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "description": "Maximum number of returned resources", + "default": 100, + "title": "Limit" + }, + "description": "Maximum number of returned resources" + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataModelVersionPageResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/tax-benefit-model-versions/{version_id}": { + "get": { + "summary": "Get one selected PolicyEngine.py model version", + "operationId": "get_model_version_v2_tax_benefit_model_versions__version_id__get", + "parameters": [ + { + "name": "version_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Version Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataModelVersionDetailResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/variables": { + "get": { + "summary": "List variables from a selected PolicyEngine.py catalog", + "operationId": "list_variables_v2_variables_get", + "parameters": [ + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "description": "Zero-based result offset", + "default": 0, + "title": "Offset" + }, + "description": "Zero-based result offset" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "description": "Maximum number of returned resources", + "default": 100, + "title": "Limit" + }, + "description": "Maximum number of returned resources" + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Case-insensitive substring of the name, label or description", + "title": "Search" + }, + "description": "Case-insensitive substring of the name, label or description" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataVariablePageResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/variables/{variable_id}": { + "get": { + "summary": "Get one variable from a selected PolicyEngine.py catalog", + "operationId": "get_variable_v2_variables__variable_id__get", + "parameters": [ + { + "name": "variable_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Variable Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataVariableDetailResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/parameters": { + "get": { + "summary": "List parameters from a selected PolicyEngine.py catalog", + "operationId": "list_parameters_v2_parameters_get", + "parameters": [ + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "description": "Zero-based result offset", + "default": 0, + "title": "Offset" + }, + "description": "Zero-based result offset" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "description": "Maximum number of returned resources", + "default": 100, + "title": "Limit" + }, + "description": "Maximum number of returned resources" + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Case-insensitive substring of the name, label or description", + "title": "Search" + }, + "description": "Case-insensitive substring of the name, label or description" + }, + { + "name": "name", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Exact parameter path" + }, + "maxItems": 100 + }, + { + "type": "null" + } + ], + "description": "Keep only these exact parameter paths; repeat the key", + "title": "Name" + }, + "description": "Keep only these exact parameter paths; repeat the key" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataParameterPageResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/parameters/children": { + "get": { + "summary": "List one level of a catalog version's parameter tree", + "operationId": "list_parameter_children_v2_parameters_children_get", + "parameters": [ + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "description": "Zero-based result offset", + "default": 0, + "title": "Offset" + }, + "description": "Zero-based result offset" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "description": "Maximum number of returned resources", + "default": 100, + "title": "Limit" + }, + "description": "Maximum number of returned resources" + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + }, + { + "name": "parent_path", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 512, + "description": "Path of the node whose direct children are listed; empty for the top level. A bracket schedule's brackets are its children.", + "default": "", + "title": "Parent Path" + }, + "description": "Path of the node whose direct children are listed; empty for the top level. A bracket schedule's brackets are its children." + }, + { + "name": "reform_changeable", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "List only the children a reform can change, sorted by label, with child counts that count only those", + "default": false, + "title": "Reform Changeable" + }, + "description": "List only the children a reform can change, sorted by label, with child counts that count only those" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataParameterChildPageResponse" + } + } + } + }, + "400": { + "description": "The resource request or PolicyEngine.py version is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request parameters do not match the resource schema.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/parameters/{parameter_id}": { + "get": { + "summary": "Get one parameter from a selected PolicyEngine.py catalog", + "operationId": "get_parameter_v2_parameters__parameter_id__get", + "parameters": [ + { + "name": "parameter_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Parameter Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataParameterDetailResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/parameter-values": { + "get": { + "summary": "List canonical values from a selected PolicyEngine.py catalog", + "operationId": "list_parameter_values_v2_parameter_values_get", + "parameters": [ + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "description": "Zero-based result offset", + "default": 0, + "title": "Offset" + }, + "description": "Zero-based result offset" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "description": "Maximum number of returned resources", + "default": 100, + "title": "Limit" + }, + "description": "Maximum number of returned resources" + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + }, + { + "name": "parameter_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "description": "Exact resource UUID" + }, + { + "type": "null" + } + ], + "title": "Parameter Id" + } + }, + { + "name": "parameter_name", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Exact parameter path" + }, + "maxItems": 100 + }, + { + "type": "null" + } + ], + "description": "Keep only these exact parameter paths' values; repeat the key", + "title": "Parameter Name" + }, + "description": "Keep only these exact parameter paths' values; repeat the key" + }, + { + "name": "current", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Keep only the values in effect today (UTC)", + "default": false, + "title": "Current" + }, + "description": "Keep only the values in effect today (UTC)" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataParameterValuePageResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/parameter-values/{value_id}": { + "get": { + "summary": "Get one canonical value from a selected PolicyEngine.py catalog", + "operationId": "get_parameter_value_v2_parameter_values__value_id__get", + "parameters": [ + { + "name": "value_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Value Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataParameterValueDetailResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/datasets": { + "get": { + "summary": "List logical inputs from a selected PolicyEngine.py catalog", + "operationId": "list_datasets_v2_datasets_get", + "parameters": [ + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "description": "Zero-based result offset", + "default": 0, + "title": "Offset" + }, + "description": "Zero-based result offset" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "description": "Maximum number of returned resources", + "default": 100, + "title": "Limit" + }, + "description": "Maximum number of returned resources" + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataDatasetPageResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/datasets/{dataset_id}": { + "get": { + "summary": "Get one logical input from a selected PolicyEngine.py catalog", + "operationId": "get_dataset_v2_datasets__dataset_id__get", + "parameters": [ + { + "name": "dataset_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Dataset Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataDatasetDetailResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/regions": { + "get": { + "summary": "List regions from a selected PolicyEngine.py catalog", + "operationId": "list_regions_v2_regions_get", + "parameters": [ + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "description": "Zero-based result offset", + "default": 0, + "title": "Offset" + }, + "description": "Zero-based result offset" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "description": "Maximum number of returned resources", + "default": 100, + "title": "Limit" + }, + "description": "Maximum number of returned resources" + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + }, + { + "name": "region_type", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/MetadataRegionType" + }, + { + "type": "null" + } + ], + "description": "Keep only regions of this type", + "title": "Region Type" + }, + "description": "Keep only regions of this type" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataRegionPageResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/regions/by-code/{region_code}": { + "get": { + "summary": "Get one region by code from a selected catalog", + "operationId": "get_region_by_code_v2_regions_by_code__region_code__get", + "parameters": [ + { + "name": "region_code", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Region Code" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataRegionDetailResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/regions/{region_id}": { + "get": { + "summary": "Get one region from a selected PolicyEngine.py catalog", + "operationId": "get_region_v2_regions__region_id__get", + "parameters": [ + { + "name": "region_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Region Id" + } + }, + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataRegionDetailResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/economy-options": { + "get": { + "summary": "Get compact economy-selection options from a selected catalog", + "operationId": "get_economy_options_v2_economy_options_get", + "parameters": [ + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataEconomyOptionsResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/model-catalog": { + "get": { + "summary": "Get the small parts of one PolicyEngine.py catalog version", + "description": "Returns the selected catalog version's model, model version, entities, basic inputs, regions, logical input datasets and economy choices, which are empty lists for a catalog without regions. The result names the selected PolicyEngine.py version, so a client can request the version's other parts by that exact version; a response for a named version may be reused for a day.", + "operationId": "get_model_catalog_v2_model_catalog_get", + "parameters": [ + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataModelCatalogResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/model-catalog/variables": { + "get": { + "summary": "Get every variable of one PolicyEngine.py catalog version", + "description": "Returns all of the selected catalog version's variables in name order, in one response. Where a variable's adds or subtracts list comes from a parameter, part_parameter_values carries that parameter's current-law values by date, so a client can pick the list for any year.", + "operationId": "get_catalog_variables_v2_model_catalog_variables_get", + "parameters": [ + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataCatalogVariablesResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/model-catalog/parameters": { + "get": { + "summary": "Get several parameters of one catalog version by name", + "description": "Returns the named parameters (repeat name for each, at most 100) in name order, each with its economy and household flags and its current-law values by date, plus the stored labels of their ancestor nodes for breadcrumbs and the requested names the version has no parameter for. A client showing or editing a reform loads all of its parameters in this one request.", + "operationId": "get_catalog_parameters_v2_model_catalog_parameters_get", + "parameters": [ + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + }, + { + "name": "name", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Exact parameter path" + }, + "minItems": 1, + "maxItems": 100, + "description": "Exact parameter paths; repeat the key for each one", + "title": "Name" + }, + "description": "Exact parameter paths; repeat the key for each one" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataCatalogParametersResponse" + } + } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the exact response body.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Includes Origin, so caches keep responses apart by origin.", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/parameters/search-file": { + "get": { + "summary": "Get a catalog version's parameter search file", + "operationId": "get_parameter_search_file_v2_parameters_search_file_get", + "parameters": [ + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "responses": { + "200": { + "description": "The catalog version's parameter search file, gzip-encoded when the request accepts gzip.", + "headers": { + "ETag": { + "description": "Strong tag of this representation: the SHA-256 of the uncompressed JSON, with \"-gzip\" appended for the gzip-encoded one.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, max-age=86400 for a named policyengine_version; public, max-age=300 for the running version.", + "schema": { + "type": "string" + } + }, + "Content-Encoding": { + "description": "gzip when the request accepts gzip; absent otherwise.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Accept-Encoding.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataParameterSearchFileContent" + } + } + } + }, + "304": { + "description": "If-None-Match names this file's content; the response has no body." + }, + "400": { + "description": "The resource request or PolicyEngine.py version is invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The request parameters do not match the resource schema.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AccountPatchRequest": { + "properties": { + "first_name": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "First Name" + }, + "last_name": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Last Name" + }, + "primary_country": { + "anyOf": [ + { + "type": "string", + "enum": [ + "us", + "uk" + ], + "description": "Supported PolicyEngine country ID" + }, + { + "type": "null" + } + ], + "title": "Primary Country" + } + }, + "additionalProperties": false, + "type": "object", + "title": "AccountPatchRequest", + "description": "Explicitly supplied account presentation fields." + }, + "FiniteJsonValue": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/FiniteJsonValue" + }, + "type": "array" + }, + { + "additionalProperties": { + "$ref": "#/components/schemas/FiniteJsonValue" + }, + "type": "object" + }, + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "HealthResponse": { + "properties": { + "status": { + "type": "string", + "const": "healthy", + "title": "Status" + } + }, + "type": "object", + "required": [ + "status" + ], + "title": "HealthResponse", + "description": "Health-check result showing that the API is up and serving requests." + }, + "HouseholdCreateRequest": { + "properties": { + "country_id": { + "type": "string", + "enum": [ + "us", + "uk" + ], + "title": "Country Id", + "description": "Supported PolicyEngine country ID" + }, + "default_year": { + "type": "integer", + "maximum": 2200, + "minimum": 1900, + "title": "Default Year", + "description": "Default year for values without explicit period keys" + }, + "household_data": { + "anyOf": [ + { + "$ref": "#/components/schemas/USHouseholdDocument-Input" + }, + { + "$ref": "#/components/schemas/UKHouseholdDocument" + } + ], + "title": "Household Data" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "country_id", + "default_year", + "household_data" + ], + "title": "HouseholdCreateRequest", + "description": "Native immutable household content with no presentation or model fields." + }, + "HouseholdDetailResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/HouseholdDetailResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "HouseholdDetailResponse", + "description": "Success response holding one household." + }, + "HouseholdDetailResult": { + "properties": { + "item": { + "$ref": "#/components/schemas/HouseholdItem" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "item" + ], + "title": "HouseholdDetailResult", + "description": "Result holding one household in ``item``." + }, + "HouseholdEntityRecord": { + "properties": { + "id": { + "type": "string", + "maxLength": 255, + "minLength": 1, + "title": "Id" + }, + "source_name": { + "anyOf": [ + { + "type": "string", + "maxLength": 255, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Source Name" + }, + "values": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "type": "object", + "maxProperties": 500, + "title": "Values" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "values" + ], + "title": "HouseholdEntityRecord", + "description": "One group entity in a household, such as a tax unit or a benefit unit.\n\n``id`` is unique within its collection and is what people's ``memberships``\nrefer to. ``values`` maps variable names to values, and ``source_name``\noptionally keeps the entity's original name, such as its key in a v1 household." + }, + "HouseholdItem": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "country_id": { + "type": "string", + "enum": [ + "us", + "uk" + ], + "title": "Country Id", + "description": "Supported PolicyEngine country ID" + }, + "default_year": { + "anyOf": [ + { + "type": "integer", + "maximum": 2200, + "minimum": 1900, + "description": "Default year for values without explicit period keys" + }, + { + "type": "null" + } + ], + "title": "Default Year" + }, + "household_data": { + "anyOf": [ + { + "$ref": "#/components/schemas/USHouseholdDocument-Output" + }, + { + "$ref": "#/components/schemas/UKHouseholdDocument" + } + ], + "title": "Household Data" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "country_id", + "default_year", + "household_data", + "created_at", + "updated_at" + ], + "title": "HouseholdItem", + "description": "A stored household.\n\nHouseholds are immutable and deduplicated: creating one with identical\ncontent returns the existing household and its ``id``." + }, + "HouseholdPageResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/HouseholdPageResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "HouseholdPageResponse", + "description": "Success response holding one page of households." + }, + "HouseholdPageResult": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/HouseholdItem" + }, + "type": "array", + "title": "Items" + }, + "offset": { + "type": "integer", + "title": "Offset" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "has_more": { + "type": "boolean", + "title": "Has More" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "items", + "offset", + "limit", + "has_more" + ], + "title": "HouseholdPageResult", + "description": "One page of households, oldest first.\n\n``offset`` and ``limit`` echo the request; when ``has_more`` is true, ask\nfor the next page with ``offset + limit``." + }, + "HouseholdPersonRecord": { + "properties": { + "id": { + "type": "string", + "maxLength": 255, + "minLength": 1, + "title": "Id" + }, + "source_name": { + "anyOf": [ + { + "type": "string", + "maxLength": 255, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Source Name" + }, + "values": { + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + }, + "type": "object", + "maxProperties": 500, + "title": "Values" + }, + "memberships": { + "additionalProperties": { + "type": "string", + "maxLength": 255, + "minLength": 1 + }, + "type": "object", + "title": "Memberships" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "values", + "memberships" + ], + "title": "HouseholdPersonRecord", + "description": "One person in a household.\n\n``memberships`` maps each of the country's entity collections to the ``id``\nof the group the person belongs to, and must name every collection." + }, + "JsonValue": {}, + "MeDetailResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MeDetailResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MeDetailResponse", + "description": "Success response holding the current user." + }, + "MeDetailResult": { + "properties": { + "item": { + "$ref": "#/components/schemas/MeItem" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "item" + ], + "title": "MeDetailResult", + "description": "Result holding the current user in ``item``." + }, + "MeExportResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MeExportResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MeExportResponse", + "description": "Success response holding the current user's export." + }, + "MeExportResult": { + "properties": { + "user": { + "anyOf": [ + { + "$ref": "#/components/schemas/MeItem" + }, + { + "type": "null" + } + ] + }, + "policies": { + "items": { + "$ref": "#/components/schemas/UserPolicyItem" + }, + "type": "array", + "title": "Policies" + }, + "households": { + "items": { + "$ref": "#/components/schemas/UserHouseholdItem" + }, + "type": "array", + "title": "Households" + }, + "simulations": { + "items": { + "$ref": "#/components/schemas/UserSimulationItem" + }, + "type": "array", + "title": "Simulations" + }, + "reports": { + "items": { + "$ref": "#/components/schemas/UserReportItem" + }, + "type": "array", + "title": "Reports" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "user", + "policies", + "households", + "simulations", + "reports" + ], + "title": "MeExportResult", + "description": "The current user and every policy, household, simulation and report it saved.\n\nThe export covers every country, not only the requested one." + }, + "MeItem": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "user_type": { + "type": "string", + "enum": [ + "unauthenticated", + "standard" + ], + "title": "User Type" + }, + "primary_country": { + "anyOf": [ + { + "type": "string", + "enum": [ + "us", + "uk" + ], + "description": "Supported PolicyEngine country ID" + }, + { + "type": "null" + } + ], + "title": "Primary Country" + }, + "first_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "First Name" + }, + "last_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Name" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Email" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "user_type", + "primary_country", + "first_name", + "last_name", + "email", + "created_at" + ], + "title": "MeItem", + "description": "The current user: a signed-in account or a browser's unauthenticated user.\n\n``user_type`` says which. An unauthenticated user has no primary country, and\nonly a signed-in user can set its names or primary country." + }, + "MeUpgradeResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MeUpgradeResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MeUpgradeResponse", + "description": "Success response holding the signed-in account after an upgrade." + }, + "MeUpgradeResult": { + "properties": { + "item": { + "$ref": "#/components/schemas/MeItem" + }, + "outcome": { + "type": "string", + "enum": [ + "converted", + "merged", + "none" + ], + "title": "Outcome" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "item", + "outcome" + ], + "title": "MeUpgradeResult", + "description": "The signed-in account after a browser's unauthenticated user moved into it.\n\n``outcome`` is ``converted`` when the unauthenticated user became the account\nin place, ``merged`` when its records moved to an existing account, and\n``none`` when there was no unauthenticated user to move." + }, + "MetadataCanonicalParameterValue": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "parameter_id": { + "type": "string", + "format": "uuid", + "title": "Parameter Id" + }, + "value": { + "$ref": "#/components/schemas/JsonValue" + }, + "start_date": { + "type": "string", + "format": "date-time", + "title": "Start Date" + }, + "end_date": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "parameter_id", + "value", + "start_date", + "end_date" + ], + "title": "MetadataCanonicalParameterValue", + "description": "One current-law value of a parameter.\n\nThe value applies from start_date until end_date, or indefinitely when\nend_date is null." + }, + "MetadataCatalogParameter": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Label" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "data_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Data Type" + }, + "unit": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Unit" + }, + "economy": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Economy" + }, + "household": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Household" + }, + "values": { + "items": { + "$ref": "#/components/schemas/MetadataDatedValue" + }, + "type": "array", + "title": "Values" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "name", + "label", + "description", + "data_type", + "unit", + "values" + ], + "title": "MetadataCatalogParameter", + "description": "A parameter with its current-law values in start-date order." + }, + "MetadataCatalogParametersResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataCatalogParametersResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataCatalogParametersResponse", + "description": "Success response holding parameters fetched by name, with their values." + }, + "MetadataCatalogParametersResult": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "catalog_format": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Catalog Format" + }, + "parameters": { + "items": { + "$ref": "#/components/schemas/MetadataCatalogParameter" + }, + "type": "array", + "title": "Parameters" + }, + "node_labels": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": "object", + "title": "Node Labels" + }, + "missing_names": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Missing Names" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "catalog_format", + "parameters", + "node_labels", + "missing_names" + ], + "title": "MetadataCatalogParametersResult", + "description": "Requested parameters of one catalog version, in name order.\n\nnode_labels maps each ancestor of a requested parameter that the version\nstores as a parameter node to the node's stored label (null when it has\nnone), for breadcrumbs; a bracket index such as ``rates[0]`` counts as a\nchild of its schedule ``rates``. missing_names lists requested names the\nversion has no parameter for." + }, + "MetadataCatalogVariablesResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataCatalogVariablesResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataCatalogVariablesResponse", + "description": "Success response holding every variable of one catalog version." + }, + "MetadataCatalogVariablesResult": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "catalog_format": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Catalog Format" + }, + "variables": { + "items": { + "$ref": "#/components/schemas/MetadataVariable" + }, + "type": "array", + "title": "Variables" + }, + "part_parameter_values": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/MetadataDatedValue" + }, + "type": "array" + }, + "type": "object", + "title": "Part Parameter Values" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "catalog_format", + "variables", + "part_parameter_values" + ], + "title": "MetadataCatalogVariablesResult", + "description": "Every variable of one catalog version, in name order.\n\npart_parameter_values has an entry for each parameter path a variable's\nadds_parameter_path or subtracts_parameter_path names: that parameter's\ncurrent-law values in start-date order, or an empty list when the\ncatalog has no such parameter." + }, + "MetadataDataset": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "year": { + "type": "integer", + "title": "Year" + }, + "storage_path": { + "type": "null", + "title": "Storage Path" + }, + "is_output_dataset": { + "type": "boolean", + "const": false, + "title": "Is Output Dataset", + "default": false + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "name", + "description", + "year" + ], + "title": "MetadataDataset", + "description": "A named input dataset available to the selected model version.\n\nThese datasets are logical inputs, so storage_path is always null and\nis_output_dataset is always false." + }, + "MetadataDatasetDetailResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataDetailResult_MetadataDataset_" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataDatasetDetailResponse", + "description": "Success response holding one dataset." + }, + "MetadataDatasetOption": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "label": { + "type": "string", + "title": "Label" + }, + "default": { + "type": "boolean", + "const": true, + "title": "Default", + "default": true + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "label" + ], + "title": "MetadataDatasetOption", + "description": "The national dataset, offered as the default for an economy calculation." + }, + "MetadataDatasetPageResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataPageResult_MetadataDataset_" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataDatasetPageResponse", + "description": "Success response holding one page of datasets." + }, + "MetadataDatedValue": { + "properties": { + "start_date": { + "type": "string", + "format": "date-time", + "title": "Start Date" + }, + "end_date": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "End Date" + }, + "value": { + "$ref": "#/components/schemas/JsonValue" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "start_date", + "end_date", + "value" + ], + "title": "MetadataDatedValue", + "description": "A current-law parameter value from start_date until end_date.\n\nA null end_date means the value applies until a later value starts, or\nindefinitely." + }, + "MetadataDetailResult_MetadataCanonicalParameterValue_": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "item": { + "$ref": "#/components/schemas/MetadataCanonicalParameterValue" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "item" + ], + "title": "MetadataDetailResult[MetadataCanonicalParameterValue]" + }, + "MetadataDetailResult_MetadataDataset_": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "item": { + "$ref": "#/components/schemas/MetadataDataset" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "item" + ], + "title": "MetadataDetailResult[MetadataDataset]" + }, + "MetadataDetailResult_MetadataModelVersionDetail_": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "item": { + "$ref": "#/components/schemas/MetadataModelVersionDetail" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "item" + ], + "title": "MetadataDetailResult[MetadataModelVersionDetail]" + }, + "MetadataDetailResult_MetadataModel_": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "item": { + "$ref": "#/components/schemas/MetadataModel" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "item" + ], + "title": "MetadataDetailResult[MetadataModel]" + }, + "MetadataDetailResult_MetadataParameterSummary_": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "item": { + "$ref": "#/components/schemas/MetadataParameterSummary" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "item" + ], + "title": "MetadataDetailResult[MetadataParameterSummary]" + }, + "MetadataDetailResult_MetadataRegion_": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "item": { + "$ref": "#/components/schemas/MetadataRegion" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "item" + ], + "title": "MetadataDetailResult[MetadataRegion]" + }, + "MetadataDetailResult_MetadataVariable_": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "item": { + "$ref": "#/components/schemas/MetadataVariable" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "item" + ], + "title": "MetadataDetailResult[MetadataVariable]" + }, + "MetadataEconomyOptions": { + "properties": { + "region": { + "items": { + "$ref": "#/components/schemas/MetadataRegionOption" + }, + "type": "array", + "title": "Region" + }, + "time_period": { + "items": { + "$ref": "#/components/schemas/MetadataTimePeriodOption" + }, + "type": "array", + "title": "Time Period" + }, + "datasets": { + "items": { + "$ref": "#/components/schemas/MetadataDatasetOption" + }, + "type": "array", + "title": "Datasets" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "region", + "time_period", + "datasets" + ], + "title": "MetadataEconomyOptions", + "description": "The region, year and dataset choices for an economy calculation.\n\nEvery list is empty for a catalog without regions, such as a\nhousehold-only country's, which offers no economy calculation." + }, + "MetadataEconomyOptionsResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataEconomyOptionsResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataEconomyOptionsResponse", + "description": "Success response holding the options for configuring an economy comparison." + }, + "MetadataEconomyOptionsResult": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "current_law_id": { + "type": "integer", + "title": "Current Law Id" + }, + "region": { + "items": { + "$ref": "#/components/schemas/MetadataRegionOption" + }, + "type": "array", + "title": "Region" + }, + "time_period": { + "items": { + "$ref": "#/components/schemas/MetadataTimePeriodOption" + }, + "type": "array", + "title": "Time Period" + }, + "datasets": { + "items": { + "$ref": "#/components/schemas/MetadataDatasetOption" + }, + "type": "array", + "title": "Datasets" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "current_law_id", + "region", + "time_period", + "datasets" + ], + "title": "MetadataEconomyOptionsResult", + "description": "The choices for configuring an economy calculation.\n\nIt lists the selectable regions, years and datasets, and the id of the\ncurrent-law policy to use as a baseline." + }, + "MetadataEntity": { + "properties": { + "key": { + "type": "string", + "title": "Key" + }, + "plural": { + "type": "string", + "title": "Plural" + }, + "is_person": { + "type": "boolean", + "title": "Is Person" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "key", + "plural", + "is_person" + ], + "title": "MetadataEntity", + "description": "A kind of member a household holds, such as a person or a tax unit.\n\nplural is the key the household's members of this kind are listed under,\nand is_person marks the entity every household input about a person uses." + }, + "MetadataEnumValue": { + "properties": { + "value": { + "type": "string", + "title": "Value" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Label" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "value", + "label" + ], + "title": "MetadataEnumValue", + "description": "One value of an enum variable: its stored name and its display label.\n\nThe label keeps the type the country package declares it with." + }, + "MetadataModel": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "name", + "description" + ], + "title": "MetadataModel", + "description": "A country's tax-benefit model in the selected PolicyEngine.py catalog." + }, + "MetadataModelCatalogResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataModelCatalogResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataModelCatalogResponse", + "description": "Success response holding the small parts of one catalog version." + }, + "MetadataModelCatalogResult": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "catalog_format": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Catalog Format" + }, + "model": { + "$ref": "#/components/schemas/MetadataModel" + }, + "model_version": { + "$ref": "#/components/schemas/MetadataModelVersionDetail" + }, + "entities": { + "items": { + "$ref": "#/components/schemas/MetadataEntity" + }, + "type": "array", + "title": "Entities" + }, + "basic_inputs": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Basic Inputs" + }, + "regions": { + "items": { + "$ref": "#/components/schemas/MetadataRegion" + }, + "type": "array", + "title": "Regions" + }, + "datasets": { + "items": { + "$ref": "#/components/schemas/MetadataDataset" + }, + "type": "array", + "title": "Datasets" + }, + "economy_options": { + "$ref": "#/components/schemas/MetadataEconomyOptions" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "catalog_format", + "model", + "model_version", + "entities", + "basic_inputs", + "regions", + "datasets", + "economy_options" + ], + "title": "MetadataModelCatalogResult", + "description": "The small parts of one catalog version, read when a client starts.\n\npolicyengine_version names the selected catalog version, so a client can\nrequest its other parts for that exact version. catalog_format is null,\nand entities and basic_inputs are empty, for a version published before\ncatalog format 2. Variables, parameters and their values are served by\ntheir own routes." + }, + "MetadataModelDetailResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataDetailResult_MetadataModel_" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataModelDetailResponse", + "description": "Success response holding one tax-benefit model." + }, + "MetadataModelPageResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataPageResult_MetadataModel_" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataModelPageResponse", + "description": "Success response holding a page of the selected catalog's tax-benefit models.\n\nA catalog has one model per country, so only the first page has an item." + }, + "MetadataModelSelectionResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataModelSelectionResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataModelSelectionResponse", + "description": "Success response holding a country's model and its selected model version." + }, + "MetadataModelSelectionResult": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "model": { + "$ref": "#/components/schemas/MetadataModel" + }, + "model_version": { + "$ref": "#/components/schemas/MetadataModelVersionDetail" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "model", + "model_version" + ], + "title": "MetadataModelSelectionResult", + "description": "A country's model together with the model version selected for the request." + }, + "MetadataModelVersionDetail": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "model_id": { + "type": "string", + "format": "uuid", + "title": "Model Id" + }, + "version": { + "type": "string", + "title": "Version" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "current_law_id": { + "type": "integer", + "title": "Current Law Id" + }, + "metadata_time_periods": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Metadata Time Periods" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "model_id", + "version", + "description", + "current_law_id", + "metadata_time_periods" + ], + "title": "MetadataModelVersionDetail", + "description": "A model version, with its current-law policy id and the years offered." + }, + "MetadataModelVersionDetailResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataDetailResult_MetadataModelVersionDetail_" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataModelVersionDetailResponse", + "description": "Success response holding one model version." + }, + "MetadataModelVersionPageResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataPageResult_MetadataModelVersionDetail_" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataModelVersionPageResponse", + "description": "Success response holding a page of the selected catalog's model versions.\n\nA catalog has one model version, so only the first page has an item." + }, + "MetadataPageResult_MetadataCanonicalParameterValue_": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "items": { + "items": { + "$ref": "#/components/schemas/MetadataCanonicalParameterValue" + }, + "type": "array", + "title": "Items" + }, + "offset": { + "type": "integer", + "title": "Offset" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "has_more": { + "type": "boolean", + "title": "Has More" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "items", + "offset", + "limit", + "has_more" + ], + "title": "MetadataPageResult[MetadataCanonicalParameterValue]" + }, + "MetadataPageResult_MetadataDataset_": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "items": { + "items": { + "$ref": "#/components/schemas/MetadataDataset" + }, + "type": "array", + "title": "Items" + }, + "offset": { + "type": "integer", + "title": "Offset" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "has_more": { + "type": "boolean", + "title": "Has More" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "items", + "offset", + "limit", + "has_more" + ], + "title": "MetadataPageResult[MetadataDataset]" + }, + "MetadataPageResult_MetadataModelVersionDetail_": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "items": { + "items": { + "$ref": "#/components/schemas/MetadataModelVersionDetail" + }, + "type": "array", + "title": "Items" + }, + "offset": { + "type": "integer", + "title": "Offset" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "has_more": { + "type": "boolean", + "title": "Has More" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "items", + "offset", + "limit", + "has_more" + ], + "title": "MetadataPageResult[MetadataModelVersionDetail]" + }, + "MetadataPageResult_MetadataModel_": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "items": { + "items": { + "$ref": "#/components/schemas/MetadataModel" + }, + "type": "array", + "title": "Items" + }, + "offset": { + "type": "integer", + "title": "Offset" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "has_more": { + "type": "boolean", + "title": "Has More" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "items", + "offset", + "limit", + "has_more" + ], + "title": "MetadataPageResult[MetadataModel]" + }, + "MetadataPageResult_MetadataParameterChild_": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "items": { + "items": { + "$ref": "#/components/schemas/MetadataParameterChild" + }, + "type": "array", + "title": "Items" + }, + "offset": { + "type": "integer", + "title": "Offset" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "has_more": { + "type": "boolean", + "title": "Has More" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "items", + "offset", + "limit", + "has_more" + ], + "title": "MetadataPageResult[MetadataParameterChild]" + }, + "MetadataPageResult_MetadataParameterSummary_": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "items": { + "items": { + "$ref": "#/components/schemas/MetadataParameterSummary" + }, + "type": "array", + "title": "Items" + }, + "offset": { + "type": "integer", + "title": "Offset" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "has_more": { + "type": "boolean", + "title": "Has More" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "items", + "offset", + "limit", + "has_more" + ], + "title": "MetadataPageResult[MetadataParameterSummary]" + }, + "MetadataPageResult_MetadataRegion_": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "items": { + "items": { + "$ref": "#/components/schemas/MetadataRegion" + }, + "type": "array", + "title": "Items" + }, + "offset": { + "type": "integer", + "title": "Offset" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "has_more": { + "type": "boolean", + "title": "Has More" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "items", + "offset", + "limit", + "has_more" + ], + "title": "MetadataPageResult[MetadataRegion]" + }, + "MetadataPageResult_MetadataVariable_": { + "properties": { + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "items": { + "items": { + "$ref": "#/components/schemas/MetadataVariable" + }, + "type": "array", + "title": "Items" + }, + "offset": { + "type": "integer", + "title": "Offset" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "has_more": { + "type": "boolean", + "title": "Has More" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "policyengine_version", + "items", + "offset", + "limit", + "has_more" + ], + "title": "MetadataPageResult[MetadataVariable]" + }, + "MetadataParameterChild": { + "properties": { + "path": { + "type": "string", + "title": "Path" + }, + "label": { + "type": "string", + "title": "Label" + }, + "type": { + "type": "string", + "enum": [ + "node", + "parameter" + ], + "title": "Type" + }, + "child_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Child Count" + }, + "parameter": { + "anyOf": [ + { + "$ref": "#/components/schemas/MetadataParameterSummary" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "path", + "label", + "type" + ], + "title": "MetadataParameterChild", + "description": "A direct child in the parameter tree.\n\nA node has a child_count; a parameter leaf carries the parameter's summary." + }, + "MetadataParameterChildPageResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataPageResult_MetadataParameterChild_" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataParameterChildPageResponse", + "description": "Success response holding one page of a parameter path's direct children." + }, + "MetadataParameterDetailResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataDetailResult_MetadataParameterSummary_" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataParameterDetailResponse", + "description": "Success response holding one parameter." + }, + "MetadataParameterPageResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataPageResult_MetadataParameterSummary_" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataParameterPageResponse", + "description": "Success response holding one page of parameters." + }, + "MetadataParameterSummary": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Label" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "data_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Data Type" + }, + "unit": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Unit" + }, + "economy": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Economy" + }, + "household": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Household" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "name", + "label", + "description", + "data_type", + "unit" + ], + "title": "MetadataParameterSummary", + "description": "A policy parameter, described without its values.\n\nIts current-law values are listed by GET /v2/parameter-values. economy and\nhousehold say whether a reform of an economy or of a household may change\nit; they are null in versions published before catalog format 2." + }, + "MetadataParameterValueDetailResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataDetailResult_MetadataCanonicalParameterValue_" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataParameterValueDetailResponse", + "description": "Success response holding one parameter value." + }, + "MetadataParameterValuePageResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataPageResult_MetadataCanonicalParameterValue_" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataParameterValuePageResponse", + "description": "Success response holding one page of parameter values." + }, + "MetadataRegion": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "code": { + "type": "string", + "title": "Code" + }, + "label": { + "type": "string", + "title": "Label" + }, + "region_type": { + "$ref": "#/components/schemas/MetadataRegionType" + }, + "requires_filter": { + "type": "boolean", + "title": "Requires Filter" + }, + "filter_field": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter Field" + }, + "filter_value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter Value" + }, + "filter_strategy": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter Strategy" + }, + "parent_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parent Code" + }, + "state_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "State Code" + }, + "state_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "State Name" + }, + "default_dataset_id": { + "type": "string", + "format": "uuid", + "title": "Default Dataset Id" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "code", + "label", + "region_type", + "requires_filter", + "filter_field", + "filter_value", + "filter_strategy", + "parent_code", + "state_code", + "state_name", + "default_dataset_id" + ], + "title": "MetadataRegion", + "description": "A region the model can simulate, such as a country, state or constituency.\n\nWhen requires_filter is true, filter_strategy says how the region selects\nhouseholds of its default dataset. With \"row_filter\", it keeps the\nhouseholds whose filter_field column equals filter_value (or is in it,\nwhen filter_value is a JSON list). With \"weight_replacement\", it keeps\nevery household and gives each the weight in row filter_value of the\nweight matrix that filter_field names in the certified data release." + }, + "MetadataRegionDetailResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataDetailResult_MetadataRegion_" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataRegionDetailResponse", + "description": "Success response holding one region." + }, + "MetadataRegionOption": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "label": { + "type": "string", + "title": "Label" + }, + "type": { + "$ref": "#/components/schemas/MetadataRegionType" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "label", + "type" + ], + "title": "MetadataRegionOption", + "description": "A region that can be chosen for an economy calculation; name is its code." + }, + "MetadataRegionPageResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataPageResult_MetadataRegion_" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataRegionPageResponse", + "description": "Success response holding one page of regions." + }, + "MetadataRegionType": { + "type": "string", + "enum": [ + "national", + "country", + "state", + "congressional_district", + "constituency", + "local_authority", + "city", + "place" + ], + "title": "MetadataRegionType", + "description": "The geographic level of a region." + }, + "MetadataTimePeriodOption": { + "properties": { + "name": { + "type": "integer", + "title": "Name" + }, + "label": { + "type": "string", + "title": "Label" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "label" + ], + "title": "MetadataTimePeriodOption", + "description": "A year that can be chosen for an economy calculation." + }, + "MetadataVariable": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Label" + }, + "entity": { + "type": "string", + "title": "Entity" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "data_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Data Type" + }, + "possible_values": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Possible Values" + }, + "default_value": { + "$ref": "#/components/schemas/JsonValue" + }, + "adds": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Adds" + }, + "subtracts": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Subtracts" + }, + "is_input_variable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Input Variable" + }, + "hidden_input": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Hidden Input" + }, + "module_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Module Name" + }, + "adds_parameter_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Adds Parameter Path" + }, + "subtracts_parameter_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Subtracts Parameter Path" + }, + "enum_values": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/MetadataEnumValue" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Enum Values" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "name", + "label", + "entity", + "description", + "data_type", + "possible_values", + "default_value", + "adds", + "subtracts" + ], + "title": "MetadataVariable", + "description": "A variable the tax-benefit model computes or accepts as input.\n\nadds and subtracts, when present, list the variables summed into or\nsubtracted from this one. When the model takes that list from a\nparameter, adds_parameter_path or subtracts_parameter_path names the\nparameter, whose dated values give the list for each year (adds and\nsubtracts hold its value on 2025-01-01). The catalog format 2 fields\n(is_input_variable, hidden_input, module_name, the two parameter paths\nand enum_values) are null in versions published before that format." + }, + "MetadataVariableDetailResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataDetailResult_MetadataVariable_" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataVariableDetailResponse", + "description": "Success response holding one variable." + }, + "MetadataVariablePageResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataPageResult_MetadataVariable_" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataVariablePageResponse", + "description": "Success response holding one page of variables." + }, + "PolicyCreateRequest": { + "properties": { + "country_id": { + "type": "string", + "enum": [ + "us", + "uk" + ], + "title": "Country Id", + "description": "Supported PolicyEngine country ID" + }, + "tax_benefit_model_id": { + "type": "string", + "format": "uuid", + "title": "Tax Benefit Model Id" + }, + "parameter_values": { + "items": { + "$ref": "#/components/schemas/PolicyParameterValueInput" + }, + "type": "array", + "maxItems": 1000, + "title": "Parameter Values" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "country_id", + "tax_benefit_model_id", + "parameter_values" + ], + "title": "PolicyCreateRequest", + "description": "Native body containing immutable policy content only." + }, + "PolicyDetailResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/PolicyDetailResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "PolicyDetailResponse", + "description": "Success response holding one policy." + }, + "PolicyDetailResult": { + "properties": { + "item": { + "$ref": "#/components/schemas/PolicyItem" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "item" + ], + "title": "PolicyDetailResult", + "description": "Result holding one policy in ``item``." + }, + "PolicyItem": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "country_id": { + "type": "string", + "title": "Country Id" + }, + "tax_benefit_model_id": { + "type": "string", + "format": "uuid", + "title": "Tax Benefit Model Id" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + }, + "parameter_values": { + "items": { + "$ref": "#/components/schemas/PolicyParameterValueItem" + }, + "type": "array", + "title": "Parameter Values" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "country_id", + "tax_benefit_model_id", + "created_at", + "updated_at", + "parameter_values" + ], + "title": "PolicyItem", + "description": "A stored policy: parameter values for one tax-benefit model.\n\nPolicies are immutable and deduplicated: creating one with identical\ncontent returns the existing policy and its ``id``." + }, + "PolicyPageResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/PolicyPageResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "PolicyPageResponse", + "description": "Success response holding one page of policies." + }, + "PolicyPageResult": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/PolicyItem" + }, + "type": "array", + "title": "Items" + }, + "offset": { + "type": "integer", + "title": "Offset" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "has_more": { + "type": "boolean", + "title": "Has More" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "items", + "offset", + "limit", + "has_more" + ], + "title": "PolicyPageResult", + "description": "One page of policies, oldest first.\n\n``offset`` and ``limit`` echo the request; when ``has_more`` is true, ask\nfor the next page with ``offset + limit``." + }, + "PolicyParameterValueInput": { + "properties": { + "parameter_id": { + "type": "string", + "format": "uuid", + "title": "Parameter Id" + }, + "value": { + "$ref": "#/components/schemas/JsonValue" + }, + "start_date": { + "type": "string", + "format": "date-time", + "title": "Start Date" + }, + "end_date": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "parameter_id", + "value", + "start_date" + ], + "title": "PolicyParameterValueInput", + "description": "One normalized effective value for a catalog parameter UUID." + }, + "PolicyParameterValueItem": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "parameter_id": { + "type": "string", + "format": "uuid", + "title": "Parameter Id" + }, + "parameter_name": { + "type": "string", + "title": "Parameter Name" + }, + "value": { + "$ref": "#/components/schemas/JsonValue" + }, + "start_date": { + "type": "string", + "format": "date-time", + "title": "Start Date" + }, + "end_date": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "parameter_id", + "parameter_name", + "value", + "start_date", + "end_date" + ], + "title": "PolicyParameterValueItem", + "description": "One value a policy sets for a parameter from ``start_date``.\n\n``parameter_name`` is the parameter's path in the model, and a null\n``end_date`` means the value has no end." + }, + "PolicyProvenance": { + "properties": { + "source": { + "type": "string", + "enum": [ + "manual", + "chat", + "bill", + "tool" + ], + "title": "Source" + }, + "ref": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048 + }, + { + "type": "null" + } + ], + "title": "Ref" + }, + "confidence": { + "anyOf": [ + { + "type": "string", + "enum": [ + "exact", + "approximated", + "partial" + ] + }, + { + "type": "null" + } + ], + "title": "Confidence" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "source" + ], + "title": "PolicyProvenance", + "description": "Where a v3 reform came from." + }, + "PolicyProvenanceItem": { + "properties": { + "source": { + "type": "string", + "title": "Source" + }, + "ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ref" + }, + "confidence": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Confidence" + } + }, + "type": "object", + "required": [ + "source" + ], + "title": "PolicyProvenanceItem", + "description": "Stored v3 reform provenance as responses publish it.\n\nIt mirrors the contract without the input bounds and ignores unknown keys,\nso any stored row serializes." + }, + "ReportCalibration": { + "properties": { + "release_id": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Release Id" + }, + "geography": { + "type": "string", + "maxLength": 64, + "title": "Geography" + }, + "reached_count": { + "type": "integer", + "title": "Reached Count" + }, + "matches": { + "items": { + "$ref": "#/components/schemas/ReportCalibrationMatch" + }, + "type": "array", + "maxItems": 500, + "title": "Matches" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "geography", + "reached_count", + "matches" + ], + "title": "ReportCalibration", + "description": "Calibration results for one geography, with at most 500 variable matches." + }, + "ReportCalibrationItem": { + "properties": { + "release_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Release Id" + }, + "geography": { + "type": "string", + "title": "Geography" + }, + "reached_count": { + "type": "integer", + "title": "Reached Count" + }, + "matches": { + "items": { + "$ref": "#/components/schemas/ReportCalibrationMatchItem" + }, + "type": "array", + "title": "Matches" + } + }, + "type": "object", + "required": [ + "geography", + "reached_count", + "matches" + ], + "title": "ReportCalibrationItem", + "description": "Calibration results saved with a report, for one geography and data release." + }, + "ReportCalibrationMatch": { + "properties": { + "variable": { + "type": "string", + "maxLength": 255, + "title": "Variable" + }, + "depth": { + "type": "integer", + "title": "Depth" + }, + "ring": { + "type": "string", + "enum": [ + "primary", + "mechanism", + "downstream" + ], + "title": "Ring" + }, + "target_count": { + "type": "integer", + "title": "Target Count" + }, + "mean_abs_relative_error": { + "type": "number", + "title": "Mean Abs Relative Error" + }, + "worst": { + "$ref": "#/components/schemas/ReportCalibrationWorst" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "variable", + "depth", + "ring", + "target_count", + "mean_abs_relative_error", + "worst" + ], + "title": "ReportCalibrationMatch", + "description": "Calibration of one variable: how closely its targets are met.\n\n``ring`` is ``primary``, ``mechanism`` or ``downstream``.\n``mean_abs_relative_error`` is averaged over the variable's ``target_count``\ntargets, and ``worst`` is the target with the largest error." + }, + "ReportCalibrationMatchItem": { + "properties": { + "variable": { + "type": "string", + "title": "Variable" + }, + "depth": { + "type": "integer", + "title": "Depth" + }, + "ring": { + "type": "string", + "title": "Ring" + }, + "target_count": { + "type": "integer", + "title": "Target Count" + }, + "mean_abs_relative_error": { + "type": "number", + "title": "Mean Abs Relative Error" + }, + "worst": { + "$ref": "#/components/schemas/ReportCalibrationWorstItem" + } + }, + "type": "object", + "required": [ + "variable", + "depth", + "ring", + "target_count", + "mean_abs_relative_error", + "worst" + ], + "title": "ReportCalibrationMatchItem", + "description": "One variable's calibration match: its targets, mean error and worst target." + }, + "ReportCalibrationWorst": { + "properties": { + "name": { + "type": "string", + "maxLength": 1024, + "title": "Name" + }, + "source": { + "type": "string", + "maxLength": 1024, + "title": "Source" + }, + "geography": { + "type": "string", + "maxLength": 64, + "title": "Geography" + }, + "relative_error": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Relative Error" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "source", + "geography" + ], + "title": "ReportCalibrationWorst", + "description": "The calibration target a match fits worst, with its source and geography." + }, + "ReportCalibrationWorstItem": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "source": { + "type": "string", + "title": "Source" + }, + "geography": { + "type": "string", + "title": "Geography" + }, + "relative_error": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Relative Error" + } + }, + "type": "object", + "required": [ + "name", + "source", + "geography" + ], + "title": "ReportCalibrationWorstItem", + "description": "The calibration target that one match fits worst, with its relative error." + }, + "ReportProvision": { + "properties": { + "path": { + "type": "string", + "title": "Path" + }, + "breadcrumb": { + "type": "string", + "title": "Breadcrumb" + }, + "unit": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Unit" + }, + "baseline_value": { + "$ref": "#/components/schemas/FiniteJsonValue" + }, + "value": { + "$ref": "#/components/schemas/FiniteJsonValue" + }, + "intervals": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ReportProvisionInterval" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Intervals" + }, + "growth": { + "anyOf": [ + { + "type": "string", + "maxLength": 1024 + }, + { + "type": "null" + } + ], + "title": "Growth" + }, + "growth_start": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Growth Start" + }, + "growth_base": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Growth Base" + }, + "growth_round_to": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Growth Round To" + }, + "growth_round": { + "anyOf": [ + { + "type": "string", + "enum": [ + "nearest", + "down", + "up" + ] + }, + { + "type": "null" + } + ], + "title": "Growth Round" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "path", + "breadcrumb", + "baseline_value", + "value" + ], + "title": "ReportProvision", + "description": "One provision of the report's reform: a parameter's baseline and reform values.\n\n``path`` is the parameter path and ``breadcrumb`` its readable label, and\n``intervals`` optionally gives a dated schedule. ``growth`` says how a value\nset from one year on moves in later years (``fixed``, ``current_law`` or an\nindex's parameter path); the other ``growth_*`` fields say when it moves and\nhow grown values round. They are absent for a hand-set schedule." + }, + "ReportProvisionInterval": { + "properties": { + "parameter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parameter" + }, + "start_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "title": "Start Date" + }, + "end_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "title": "End Date" + }, + "value": { + "$ref": "#/components/schemas/FiniteJsonValue" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "start_date", + "end_date", + "value" + ], + "title": "ReportProvisionInterval", + "description": "One dated value in a provision's schedule, from ``start_date`` to ``end_date``.\n\nDates are ``YYYY-MM-DD`` strings and ``value`` is any finite JSON value.\n``parameter`` is optional, and is left out of the stored report when unset." + }, + "ReportProvisionIntervalItem": { + "properties": { + "parameter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parameter" + }, + "start_date": { + "type": "string", + "title": "Start Date" + }, + "end_date": { + "type": "string", + "title": "End Date" + }, + "value": { + "$ref": "#/components/schemas/JsonValue" + } + }, + "type": "object", + "required": [ + "start_date", + "end_date", + "value" + ], + "title": "ReportProvisionIntervalItem", + "description": "A provision's value for the dates from ``start_date`` to ``end_date``." + }, + "ReportProvisionItem": { + "properties": { + "path": { + "type": "string", + "title": "Path" + }, + "breadcrumb": { + "type": "string", + "title": "Breadcrumb" + }, + "unit": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Unit" + }, + "baseline_value": { + "$ref": "#/components/schemas/JsonValue" + }, + "value": { + "$ref": "#/components/schemas/JsonValue" + }, + "intervals": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ReportProvisionIntervalItem" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Intervals" + }, + "growth": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Growth" + }, + "growth_start": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Growth Start" + }, + "growth_base": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Growth Base" + }, + "growth_round_to": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Growth Round To" + }, + "growth_round": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Growth Round" + } + }, + "type": "object", + "required": [ + "path", + "breadcrumb", + "baseline_value", + "value" + ], + "title": "ReportProvisionItem", + "description": "One parameter the report's reform changes, with its baseline and reform values.\n\n``intervals`` gives the reform's dated values when they vary over time. The\n``growth`` keys, present only when set, say how a value set from one year on\nmoves in later years and how grown values round." + }, + "ReportScorecard": { + "properties": { + "programs": { + "items": { + "type": "string", + "maxLength": 255 + }, + "type": "array", + "maxItems": 200, + "title": "Programs" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "programs" + ], + "title": "ReportScorecard", + "description": "The programs on the report's validation scorecard, at most 200 names." + }, + "ReportScorecardItem": { + "properties": { + "programs": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Programs" + } + }, + "type": "object", + "required": [ + "programs" + ], + "title": "ReportScorecardItem", + "description": "The programs on the scorecard saved with a report's validation." + }, + "ReportValidation": { + "properties": { + "matched_at": { + "type": "string", + "maxLength": 64, + "title": "Matched At" + }, + "map_model_version": { + "type": "string", + "maxLength": 255, + "title": "Map Model Version" + }, + "calibration": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReportCalibration" + }, + { + "type": "null" + } + ] + }, + "scorecard": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReportScorecard" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "matched_at", + "map_model_version" + ], + "title": "ReportValidation", + "description": "The validation snapshot saved with a report.\n\n``calibration`` and ``scorecard`` are optional, and the stored snapshot keeps\nonly the keys supplied." + }, + "ReportValidationItem": { + "properties": { + "matched_at": { + "type": "string", + "title": "Matched At" + }, + "map_model_version": { + "type": "string", + "title": "Map Model Version" + }, + "calibration": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReportCalibrationItem" + }, + { + "type": "null" + } + ] + }, + "scorecard": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReportScorecardItem" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "matched_at", + "map_model_version" + ], + "title": "ReportValidationItem", + "description": "The validation snapshot saved with a report.\n\nIt records when the report was matched and with which map model version,\nand optionally the calibration results and scorecard." + }, + "SPMSelection": { + "oneOf": [ + { + "not": { + "properties": { + "geography_id": { + "type": "string" + } + }, + "required": [ + "geography_id" + ] + }, + "properties": { + "geography_kind": { + "enum": [ + "county", + "national" + ] + } + } + }, + { + "properties": { + "geography_kind": { + "enum": [ + "metro" + ] + }, + "geography_id": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + } + }, + "required": [ + "geography_kind", + "geography_id" + ] + } + ], + "properties": { + "forecast_content_sha256": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + { + "type": "null" + } + ], + "title": "Forecast Content Sha256" + }, + "scenario": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "pattern": "^\\S+$" + }, + { + "type": "null" + } + ], + "title": "Scenario" + }, + "geography_kind": { + "type": "string", + "enum": [ + "county", + "national", + "metro" + ], + "title": "Geography Kind" + }, + "geography_id": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Geography Id" + }, + "county_vintage": { + "type": "string", + "pattern": "^[0-9]{4}$", + "title": "County Vintage" + }, + "as_of": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$", + "format": "date", + "title": "As Of", + "description": "ISO calendar date (YYYY-MM-DD); must satisfy the selected artifact's information date" + } + }, + "additionalProperties": false, + "type": "object", + "title": "SPMSelection", + "description": "Select a forecast scenario and an explicit household SPM geography." + }, + "SharedBaselineReformItem": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name" + ], + "title": "SharedBaselineReformItem", + "description": "The saved reform a shared report was compared with, named but not linked." + }, + "SharedUserReportDetailResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/SharedUserReportDetailResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "SharedUserReportDetailResponse", + "description": "Success response holding one shared report." + }, + "SharedUserReportDetailResult": { + "properties": { + "item": { + "$ref": "#/components/schemas/SharedUserReportItem" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "item" + ], + "title": "SharedUserReportDetailResult", + "description": "Result holding one shared report in ``item``." + }, + "SharedUserReportItem": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "country_id": { + "type": "string", + "enum": [ + "us", + "uk" + ], + "title": "Country Id", + "description": "Supported PolicyEngine country ID" + }, + "legacy_report_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Legacy Report Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "source_note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Note" + }, + "provisions": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ReportProvisionItem" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Provisions" + }, + "validation": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReportValidationItem" + }, + { + "type": "null" + } + ] + }, + "budget_window_years": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Budget Window Years" + }, + "baseline_reform": { + "anyOf": [ + { + "$ref": "#/components/schemas/SharedBaselineReformItem" + }, + { + "type": "null" + } + ] + }, + "last_run_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Run At" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "country_id", + "legacy_report_id", + "name", + "source_note", + "provisions", + "validation", + "budget_window_years", + "baseline_reform", + "last_run_at", + "created_at", + "updated_at" + ], + "title": "SharedUserReportItem", + "description": "A saved report as anyone holding its link reads it." + }, + "UKHouseholdDocument": { + "properties": { + "people": { + "items": { + "$ref": "#/components/schemas/HouseholdPersonRecord" + }, + "type": "array", + "maxItems": 1000, + "title": "People" + }, + "household": { + "items": { + "$ref": "#/components/schemas/HouseholdEntityRecord" + }, + "type": "array", + "maxItems": 1000, + "title": "Household" + }, + "benunit": { + "items": { + "$ref": "#/components/schemas/HouseholdEntityRecord" + }, + "type": "array", + "maxItems": 1000, + "title": "Benunit" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "people", + "household", + "benunit" + ], + "title": "UKHouseholdDocument", + "description": "UK household content: people, households and benefit units." + }, + "USHouseholdDocument-Input": { + "properties": { + "people": { + "items": { + "$ref": "#/components/schemas/HouseholdPersonRecord" + }, + "type": "array", + "maxItems": 1000, + "title": "People" + }, + "household": { + "items": { + "$ref": "#/components/schemas/HouseholdEntityRecord" + }, + "type": "array", + "maxItems": 1000, + "title": "Household" + }, + "family": { + "items": { + "$ref": "#/components/schemas/HouseholdEntityRecord" + }, + "type": "array", + "maxItems": 1000, + "title": "Family" + }, + "tax_unit": { + "items": { + "$ref": "#/components/schemas/HouseholdEntityRecord" + }, + "type": "array", + "maxItems": 1000, + "title": "Tax Unit" + }, + "spm_unit": { + "items": { + "$ref": "#/components/schemas/HouseholdEntityRecord" + }, + "type": "array", + "maxItems": 1000, + "title": "Spm Unit" + }, + "marital_unit": { + "items": { + "$ref": "#/components/schemas/HouseholdEntityRecord" + }, + "type": "array", + "maxItems": 1000, + "title": "Marital Unit" + }, + "spm": { + "$ref": "#/components/schemas/SPMSelection", + "title": "Spm" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "people", + "household", + "family", + "tax_unit", + "spm_unit", + "marital_unit" + ], + "title": "USHouseholdDocument", + "description": "US household content: people and the US group entities they belong to.\n\n``spm`` optionally selects an SPM forecast scenario and geography; omit it\nrather than sending null." + }, + "USHouseholdDocument-Output": { + "additionalProperties": true, + "type": "object" + }, + "UserHouseholdCreateRequest": { + "properties": { + "country_id": { + "type": "string", + "enum": [ + "us", + "uk" + ], + "title": "Country Id", + "description": "Supported PolicyEngine country ID" + }, + "household_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "description": "Exact resource UUID" + }, + { + "type": "null" + } + ], + "title": "Household Id" + }, + "legacy_household_id": { + "anyOf": [ + { + "type": "integer", + "maximum": 9223372036854776000, + "minimum": 1, + "description": "Exact v1 database identifier (a JSON integer)" + }, + { + "type": "null" + } + ], + "title": "Legacy Household Id" + }, + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 10000 + }, + { + "type": "null" + } + ], + "title": "Description" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "country_id" + ], + "title": "UserHouseholdCreateRequest", + "description": "Association fields; the owner is taken from the request credential." + }, + "UserHouseholdDetailResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/UserHouseholdDetailResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "UserHouseholdDetailResponse", + "description": "Success response holding one user-household association." + }, + "UserHouseholdDetailResult": { + "properties": { + "item": { + "$ref": "#/components/schemas/UserHouseholdItem" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "item" + ], + "title": "UserHouseholdDetailResult", + "description": "Result holding one user-household association in ``item``." + }, + "UserHouseholdItem": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "country_id": { + "type": "string", + "enum": [ + "us", + "uk" + ], + "title": "Country Id", + "description": "Supported PolicyEngine country ID" + }, + "user_id": { + "type": "string", + "format": "uuid", + "title": "User Id" + }, + "household_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Household Id" + }, + "legacy_household_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Legacy Household Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "country_id", + "user_id", + "household_id", + "legacy_household_id", + "name", + "description", + "created_at", + "updated_at" + ], + "title": "UserHouseholdItem", + "description": "A household saved by a v2 user, with the user's name and description for it.\n\nUnlike households, associations can be renamed, pointed at another\nhousehold of the same country, or deleted." + }, + "UserHouseholdPageResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/UserHouseholdPageResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "UserHouseholdPageResponse", + "description": "Success response holding one page of user-household associations." + }, + "UserHouseholdPageResult": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/UserHouseholdItem" + }, + "type": "array", + "title": "Items" + }, + "offset": { + "type": "integer", + "title": "Offset" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "has_more": { + "type": "boolean", + "title": "Has More" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "items", + "offset", + "limit", + "has_more" + ], + "title": "UserHouseholdPageResult", + "description": "One page of user-household associations, oldest first.\n\n``offset`` and ``limit`` echo the request; when ``has_more`` is true, ask\nfor the next page with ``offset + limit``." + }, + "UserHouseholdPatchRequest": { + "properties": { + "household_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "description": "Exact resource UUID" + }, + { + "type": "null" + } + ], + "title": "Household Id" + }, + "legacy_household_id": { + "anyOf": [ + { + "type": "integer", + "maximum": 9223372036854776000, + "minimum": 1, + "description": "Exact v1 database identifier (a JSON integer)" + }, + { + "type": "null" + } + ], + "title": "Legacy Household Id" + }, + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 10000 + }, + { + "type": "null" + } + ], + "title": "Description" + } + }, + "additionalProperties": false, + "type": "object", + "title": "UserHouseholdPatchRequest", + "description": "Explicitly supplied mutable link or presentation fields." + }, + "UserPolicyCreateRequest": { + "properties": { + "country_id": { + "type": "string", + "enum": [ + "us", + "uk" + ], + "title": "Country Id", + "description": "Supported PolicyEngine country ID" + }, + "policy_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "description": "Exact resource UUID" + }, + { + "type": "null" + } + ], + "title": "Policy Id" + }, + "legacy_policy_id": { + "anyOf": [ + { + "type": "integer", + "maximum": 9223372036854776000, + "minimum": 1, + "description": "Exact v1 database identifier (a JSON integer)" + }, + { + "type": "null" + } + ], + "title": "Legacy Policy Id" + }, + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 10000 + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "baseline": { + "anyOf": [ + { + "type": "string", + "enum": [ + "current-law", + "current-policy" + ] + }, + { + "type": "null" + } + ], + "title": "Baseline" + }, + "provenance": { + "anyOf": [ + { + "$ref": "#/components/schemas/PolicyProvenance" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "country_id" + ], + "title": "UserPolicyCreateRequest", + "description": "Association fields; the owner is taken from the request credential." + }, + "UserPolicyDetailResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/UserPolicyDetailResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "UserPolicyDetailResponse", + "description": "Success response holding one user-policy association." + }, + "UserPolicyDetailResult": { + "properties": { + "item": { + "$ref": "#/components/schemas/UserPolicyItem" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "item" + ], + "title": "UserPolicyDetailResult", + "description": "Result holding one user-policy association in ``item``." + }, + "UserPolicyItem": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "country_id": { + "type": "string", + "enum": [ + "us", + "uk" + ], + "title": "Country Id", + "description": "Supported PolicyEngine country ID" + }, + "user_id": { + "type": "string", + "format": "uuid", + "title": "User Id" + }, + "policy_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Policy Id" + }, + "legacy_policy_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Legacy Policy Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "baseline": { + "anyOf": [ + { + "type": "string", + "enum": [ + "current-law", + "current-policy" + ] + }, + { + "type": "null" + } + ], + "title": "Baseline" + }, + "provenance": { + "anyOf": [ + { + "$ref": "#/components/schemas/PolicyProvenanceItem" + }, + { + "type": "null" + } + ] + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "country_id", + "user_id", + "policy_id", + "legacy_policy_id", + "name", + "description", + "baseline", + "provenance", + "created_at", + "updated_at" + ], + "title": "UserPolicyItem", + "description": "A policy saved by a v2 user, with the user's name and description for it.\n\nUnlike policies, associations can be renamed or deleted." + }, + "UserPolicyPageResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/UserPolicyPageResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "UserPolicyPageResponse", + "description": "Success response holding one page of user-policy associations." + }, + "UserPolicyPageResult": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/UserPolicyItem" + }, + "type": "array", + "title": "Items" + }, + "offset": { + "type": "integer", + "title": "Offset" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "has_more": { + "type": "boolean", + "title": "Has More" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "items", + "offset", + "limit", + "has_more" + ], + "title": "UserPolicyPageResult", + "description": "One page of user-policy associations, oldest first.\n\n``offset`` and ``limit`` echo the request; when ``has_more`` is true, ask\nfor the next page with ``offset + limit``." + }, + "UserPolicyPatchRequest": { + "properties": { + "policy_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "description": "Exact resource UUID" + }, + { + "type": "null" + } + ], + "title": "Policy Id" + }, + "legacy_policy_id": { + "anyOf": [ + { + "type": "integer", + "maximum": 9223372036854776000, + "minimum": 1, + "description": "Exact v1 database identifier (a JSON integer)" + }, + { + "type": "null" + } + ], + "title": "Legacy Policy Id" + }, + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 10000 + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "baseline": { + "anyOf": [ + { + "type": "string", + "enum": [ + "current-law", + "current-policy" + ] + }, + { + "type": "null" + } + ], + "title": "Baseline" + }, + "provenance": { + "anyOf": [ + { + "$ref": "#/components/schemas/PolicyProvenance" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "type": "object", + "title": "UserPolicyPatchRequest", + "description": "Explicitly supplied mutable link or presentation fields." + }, + "UserReportCreateRequest": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 10000 + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "source_note": { + "anyOf": [ + { + "type": "string", + "maxLength": 10000 + }, + { + "type": "null" + } + ], + "title": "Source Note" + }, + "provisions": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ReportProvision" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Provisions" + }, + "validation": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReportValidation" + }, + { + "type": "null" + } + ] + }, + "budget_window_years": { + "anyOf": [ + { + "type": "integer", + "maximum": 75, + "minimum": 2 + }, + { + "type": "null" + } + ], + "title": "Budget Window Years" + }, + "last_run_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Run At" + }, + "country_id": { + "type": "string", + "enum": [ + "us", + "uk" + ], + "title": "Country Id", + "description": "Supported PolicyEngine country ID" + }, + "legacy_report_id": { + "type": "integer", + "maximum": 9223372036854776000, + "minimum": 1, + "title": "Legacy Report Id", + "description": "Exact v1 database identifier (a JSON integer)" + }, + "user_policy_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "description": "Exact resource UUID" + }, + { + "type": "null" + } + ], + "title": "User Policy Id" + }, + "baseline_user_policy_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "description": "Exact resource UUID" + }, + { + "type": "null" + } + ], + "title": "Baseline User Policy Id" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "country_id", + "legacy_report_id" + ], + "title": "UserReportCreateRequest", + "description": "Association fields; the owner is taken from the request credential." + }, + "UserReportDetailResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/UserReportDetailResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "UserReportDetailResponse", + "description": "Success response holding one user-report association." + }, + "UserReportDetailResult": { + "properties": { + "item": { + "$ref": "#/components/schemas/UserReportItem" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "item" + ], + "title": "UserReportDetailResult", + "description": "Result holding one user-report association in ``item``." + }, + "UserReportItem": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "country_id": { + "type": "string", + "enum": [ + "us", + "uk" + ], + "title": "Country Id", + "description": "Supported PolicyEngine country ID" + }, + "user_id": { + "type": "string", + "format": "uuid", + "title": "User Id" + }, + "report_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Report Id" + }, + "legacy_report_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Legacy Report Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "user_policy_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "User Policy Id" + }, + "baseline_user_policy_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Baseline User Policy Id" + }, + "source_note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Note" + }, + "provisions": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ReportProvisionItem" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Provisions" + }, + "validation": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReportValidationItem" + }, + { + "type": "null" + } + ] + }, + "budget_window_years": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Budget Window Years" + }, + "last_run_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Run At" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "country_id", + "user_id", + "report_id", + "legacy_report_id", + "name", + "description", + "user_policy_id", + "baseline_user_policy_id", + "source_note", + "provisions", + "validation", + "budget_window_years", + "last_run_at", + "created_at", + "updated_at" + ], + "title": "UserReportItem", + "description": "A report saved by a user, with the user's name, notes and saved presentation.\n\n``legacy_report_id`` is the v1 report output it shows. ``user_policy_id`` is\nthe user's saved reform the report was run from, and\n``baseline_user_policy_id`` the saved reform it was compared with instead of\ncurrent law; each is null when not set or once that reform is deleted.\n``budget_window_years`` is null for a report without a budget window." + }, + "UserReportPageResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/UserReportPageResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "UserReportPageResponse", + "description": "Success response holding one page of user-report associations." + }, + "UserReportPageResult": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/UserReportItem" + }, + "type": "array", + "title": "Items" + }, + "offset": { + "type": "integer", + "title": "Offset" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "has_more": { + "type": "boolean", + "title": "Has More" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "items", + "offset", + "limit", + "has_more" + ], + "title": "UserReportPageResult", + "description": "One page of user-report associations, oldest first.\n\n``offset`` and ``limit`` echo the request; when ``has_more`` is true, ask\nfor the next page with ``offset + limit``." + }, + "UserReportPatchRequest": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 10000 + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "source_note": { + "anyOf": [ + { + "type": "string", + "maxLength": 10000 + }, + { + "type": "null" + } + ], + "title": "Source Note" + }, + "provisions": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ReportProvision" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Provisions" + }, + "validation": { + "anyOf": [ + { + "$ref": "#/components/schemas/ReportValidation" + }, + { + "type": "null" + } + ] + }, + "budget_window_years": { + "anyOf": [ + { + "type": "integer", + "maximum": 75, + "minimum": 2 + }, + { + "type": "null" + } + ], + "title": "Budget Window Years" + }, + "last_run_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Run At" + }, + "legacy_report_id": { + "anyOf": [ + { + "type": "integer", + "maximum": 9223372036854776000, + "minimum": 1, + "description": "Exact v1 database identifier (a JSON integer)" + }, + { + "type": "null" + } + ], + "title": "Legacy Report Id" + }, + "user_policy_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "description": "Exact resource UUID" + }, + { + "type": "null" + } + ], + "title": "User Policy Id" + }, + "baseline_user_policy_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "description": "Exact resource UUID" + }, + { + "type": "null" + } + ], + "title": "Baseline User Policy Id" + } + }, + "additionalProperties": false, + "type": "object", + "title": "UserReportPatchRequest", + "description": "Explicitly supplied mutable link or presentation fields." + }, + "UserSimulationCreateRequest": { + "properties": { + "country_id": { + "type": "string", + "enum": [ + "us", + "uk" + ], + "title": "Country Id", + "description": "Supported PolicyEngine country ID" + }, + "legacy_simulation_id": { + "type": "integer", + "maximum": 9223372036854776000, + "minimum": 1, + "title": "Legacy Simulation Id", + "description": "Exact v1 database identifier (a JSON integer)" + }, + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 10000 + }, + { + "type": "null" + } + ], + "title": "Description" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "country_id", + "legacy_simulation_id" + ], + "title": "UserSimulationCreateRequest", + "description": "Association fields; the owner is taken from the request credential." + }, + "UserSimulationDetailResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/UserSimulationDetailResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "UserSimulationDetailResponse", + "description": "Success response holding one user-simulation association." + }, + "UserSimulationDetailResult": { + "properties": { + "item": { + "$ref": "#/components/schemas/UserSimulationItem" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "item" + ], + "title": "UserSimulationDetailResult", + "description": "Result holding one user-simulation association in ``item``." + }, + "UserSimulationItem": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "country_id": { + "type": "string", + "enum": [ + "us", + "uk" + ], + "title": "Country Id", + "description": "Supported PolicyEngine country ID" + }, + "user_id": { + "type": "string", + "format": "uuid", + "title": "User Id" + }, + "simulation_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Simulation Id" + }, + "legacy_simulation_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Legacy Simulation Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "country_id", + "user_id", + "simulation_id", + "legacy_simulation_id", + "name", + "description", + "created_at", + "updated_at" + ], + "title": "UserSimulationItem", + "description": "A simulation saved by a user, with the user's name and description for it.\n\n``legacy_simulation_id`` is the v1 simulation it saves. Associations can be\nrenamed, pointed at another v1 simulation of the same country, or deleted." + }, + "UserSimulationPageResponse": { + "properties": { + "status": { + "type": "string", + "const": "ok", + "title": "Status", + "default": "ok" + }, + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/UserSimulationPageResult" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "UserSimulationPageResponse", + "description": "Success response holding one page of user-simulation associations." + }, + "UserSimulationPageResult": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/UserSimulationItem" + }, + "type": "array", + "title": "Items" + }, + "offset": { + "type": "integer", + "title": "Offset" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "has_more": { + "type": "boolean", + "title": "Has More" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "items", + "offset", + "limit", + "has_more" + ], + "title": "UserSimulationPageResult", + "description": "One page of user-simulation associations, oldest first.\n\n``offset`` and ``limit`` echo the request; when ``has_more`` is true, ask\nfor the next page with ``offset + limit``." + }, + "UserSimulationPatchRequest": { + "properties": { + "legacy_simulation_id": { + "anyOf": [ + { + "type": "integer", + "maximum": 9223372036854776000, + "minimum": 1, + "description": "Exact v1 database identifier (a JSON integer)" + }, + { + "type": "null" + } + ], + "title": "Legacy Simulation Id" + }, + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 10000 + }, + { + "type": "null" + } + ], + "title": "Description" + } + }, + "additionalProperties": false, + "type": "object", + "title": "UserSimulationPatchRequest", + "description": "Explicitly supplied mutable link or presentation fields." + }, + "V2ErrorResponse": { + "properties": { + "status": { + "type": "string", + "const": "error", + "title": "Status", + "default": "error" + }, + "message": { + "type": "string", + "minLength": 1, + "title": "Message" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "message" + ], + "title": "V2ErrorResponse", + "description": "Strict error response shared by every API v2 resource." + }, + "MetadataParameterSearchFileContent": { + "properties": { + "format": { + "type": "integer", + "title": "Format" + }, + "country_id": { + "type": "string", + "title": "Country Id" + }, + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "parameters": { + "items": { + "$ref": "#/components/schemas/MetadataParameterSearchFileParameter" + }, + "type": "array", + "title": "Parameters" + }, + "other_parameters": { + "items": { + "$ref": "#/components/schemas/MetadataParameterSearchFileLabel" + }, + "type": "array", + "title": "Other Parameters" + }, + "nodes": { + "items": { + "$ref": "#/components/schemas/MetadataParameterSearchFileNode" + }, + "type": "array", + "title": "Nodes" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "format", + "country_id", + "policyengine_version", + "parameters", + "other_parameters", + "nodes" + ], + "title": "MetadataParameterSearchFileContent", + "description": "One catalog version's parameter search file, ordered by path.\n\nIt holds what v3's parameter search reads, without the catalog's values.\nA reader applies v1's label fallback where a label is null." + }, + "MetadataParameterSearchFileEditabilityChange": { + "properties": { + "start_date": { + "type": "string", + "format": "date", + "title": "Start Date" + }, + "editable": { + "type": "boolean", + "title": "Editable" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "start_date", + "editable" + ], + "title": "MetadataParameterSearchFileEditabilityChange", + "description": "From ``start_date`` (an ISO date), whether a draft can edit the value." + }, + "MetadataParameterSearchFileLabel": { + "properties": { + "path": { + "type": "string", + "title": "Path" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Label" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "path", + "label" + ], + "title": "MetadataParameterSearchFileLabel", + "description": "Another parameter under ``gov``: its path and stored label, or null." + }, + "MetadataParameterSearchFileNode": { + "properties": { + "path": { + "type": "string", + "title": "Path" + }, + "label": { + "type": "string", + "title": "Label" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "path", + "label" + ], + "title": "MetadataParameterSearchFileNode", + "description": "A node under ``gov`` that has a stored label." + }, + "MetadataParameterSearchFileParameter": { + "properties": { + "path": { + "type": "string", + "title": "Path" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Label" + }, + "unit": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Unit" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "editable": { + "type": "boolean", + "title": "Editable" + }, + "editable_changes": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/MetadataParameterSearchFileEditabilityChange" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Editable Changes" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "path", + "label", + "unit", + "description", + "editable" + ], + "title": "MetadataParameterSearchFileParameter", + "description": "A parameter a reform can change, with what v3's search indexes.\n\n``label`` is the stored label, or null when none is stored. ``editable``\nsays whether a draft can edit the earliest current-law value (a number, a\nyes/no value or an infinite threshold); ``editable_changes``, when\npresent, lists the dates from which that changes." + } + }, + "securitySchemes": { + "UserAccessToken": { + "type": "http", + "description": "Auth0 user access token issued for the Public API audience.", + "scheme": "bearer" + }, + "UnauthenticatedUser": { + "type": "apiKey", + "description": "Canonical UUID that a signed-out browser generates once and keeps. It is a bearer credential for that browser's unauthenticated user; ignored when a bearer access token is present.", + "in": "header", + "name": "PolicyEngine-Unauthenticated-User" + } + } + } +} diff --git a/app/src/api/publicApi/schema.d.ts b/app/src/api/publicApi/schema.d.ts new file mode 100644 index 000000000..5df065bb9 --- /dev/null +++ b/app/src/api/publicApi/schema.d.ts @@ -0,0 +1,8655 @@ +/** + * Generated from src/api/publicApi/openapi.json by scripts/public-api-client.ts. + * Do not edit: run `bun run public-api:generate` (or `public-api:refresh`). + */ + +export interface paths { + '/v2/me': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read the current user + * @description For a bearer access token, returns the PolicyEngine user, creating it on the first call with primary_country set from country_id. For the PolicyEngine-Unauthenticated-User header, returns the browser's unauthenticated user, or 404 before its first saved record; reading never creates one. + */ + get: operations['get_me_v2_me_get']; + put?: never; + post?: never; + /** + * Delete the current user and all of their saved records + * @description For a signed-in user, obtains Auth0 Management credentials first when they are configured, then deletes the user's legacy user mappings, the user (identities and associations cascade), and the Auth0 user. For an unauthenticated user, deletes the user and its associations and never calls Auth0; 204 even when it does not exist. + */ + delete: operations['delete_me_v2_me_delete']; + options?: never; + head?: never; + /** + * Update the signed-in user's names or primary country + * @description Unauthenticated users cannot edit account details (403). + */ + patch: operations['patch_me_v2_me_patch']; + trace?: never; + }; + '/v2/me/export': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Export the current user and every saved record + * @description Covers every country, not only country_id. user is null, and every list empty, for an unauthenticated user with no saved records. + */ + get: operations['export_me_v2_me_export_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/me/upgrade': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Move this browser's unauthenticated user into the signed-in account + * @description Requires both a bearer access token and the PolicyEngine-Unauthenticated-User header. When the signed-in identity has no account yet, the unauthenticated user becomes it in place, keeping its ID and records (converted). When the identity already has an account, every record moves to that account with its ID unchanged and the unauthenticated user is deleted (merged). When the header names no unauthenticated user, including a signed-in account's ID, nothing moves (none). The response is the signed-in account. + */ + post: operations['upgrade_me_v2_me_upgrade_post']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/user-reports': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the current user's saved reports */ + get: operations['get_user_reports_v2_user_reports_get']; + put?: never; + /** + * Save a v1 report for the current user + * @description Requires a bearer access token or the PolicyEngine-Unauthenticated-User header; the owner is taken from that credential. Stage 12A accepts legacy_report_id only (the requested v1 report output ID), checked in the v1 database. An optional user_policy_id must name the same user's saved policy in the same country. + */ + post: operations['create_user_report_v2_user_reports_post']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/user-reports/{association_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read one of the current user's saved reports */ + get: operations['get_user_report_v2_user_reports__association_id__get']; + put?: never; + post?: never; + /** Delete one of the current user's saved reports */ + delete: operations['delete_user_report_route_v2_user_reports__association_id__delete']; + options?: never; + head?: never; + /** Update or repoint one of the current user's saved reports */ + patch: operations['patch_user_report_route_v2_user_reports__association_id__patch']; + trace?: never; + }; + '/v2/shared-reports/{association_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read a saved report by its shareable link + * @description Needs no credential: a saved report's ID is its link, and anyone holding it can read the report. The owner and the owner's saved-reform IDs are left out; the reform the report was compared with is named instead. + */ + get: operations['get_shared_report_v2_shared_reports__association_id__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/user-simulations': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the current user's saved simulations */ + get: operations['get_user_simulations_v2_user_simulations_get']; + put?: never; + /** + * Save a v1 simulation for the current user + * @description Requires a bearer access token or the PolicyEngine-Unauthenticated-User header; the owner is taken from that credential. Stage 12A accepts legacy_simulation_id only, checked in the v1 database. + */ + post: operations['create_user_simulation_v2_user_simulations_post']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/user-simulations/{association_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read one of the current user's saved simulations */ + get: operations['get_user_simulation_v2_user_simulations__association_id__get']; + put?: never; + post?: never; + /** Delete one of the current user's saved simulations */ + delete: operations['delete_user_simulation_route_v2_user_simulations__association_id__delete']; + options?: never; + head?: never; + /** Update or repoint one of the current user's saved simulations */ + patch: operations['patch_user_simulation_route_v2_user_simulations__association_id__patch']; + trace?: never; + }; + '/v2/user-households': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the current user's saved households */ + get: operations['get_user_households_v2_user_households_get']; + put?: never; + /** + * Save a household for the current user + * @description Requires a bearer access token or the PolicyEngine-Unauthenticated-User header; the owner is taken from that credential. Accepts legacy_household_id (a v1 household, checked in the v1 database) or household_id (a v2 household). + */ + post: operations['create_user_household_v2_user_households_post']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/user-households/{association_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read one of the current user's saved households */ + get: operations['get_user_household_v2_user_households__association_id__get']; + put?: never; + post?: never; + /** Delete one of the current user's saved households */ + delete: operations['delete_user_household_route_v2_user_households__association_id__delete']; + options?: never; + head?: never; + /** + * Update or repoint one of the current user's saved households + * @description The row keeps its id, owner and creation time. Changing legacy_household_id repoints the row and re-resolves household_id from the Stage 11 copy mapping, or clears it. Changing household_id alone points the row at that v2 household, which must exist in the same country, and clears legacy_household_id. Supplied together, both are stored, and household_id must be the copy of legacy_household_id when one exists. + */ + patch: operations['patch_user_household_route_v2_user_households__association_id__patch']; + trace?: never; + }; + '/v2/user-policies': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the current user's saved policies */ + get: operations['get_user_policies_v2_user_policies_get']; + put?: never; + /** + * Save a policy for the current user + * @description Requires a bearer access token or the PolicyEngine-Unauthenticated-User header; the owner is taken from that credential. Accepts legacy_policy_id (a v1 policy, checked in the v1 database) or policy_id (a v2 policy). When only legacy_policy_id is supplied, policy_id is filled from the Stage 10 mirror mapping when one exists. + */ + post: operations['create_user_policy_v2_user_policies_post']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/user-policies/{association_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read one of the current user's saved policies */ + get: operations['get_user_policy_v2_user_policies__association_id__get']; + put?: never; + post?: never; + /** Delete one of the current user's saved policies */ + delete: operations['delete_user_policy_route_v2_user_policies__association_id__delete']; + options?: never; + head?: never; + /** + * Update or repoint one of the current user's saved policies + * @description The row keeps its id, owner and creation time. Changing legacy_policy_id repoints the row and re-resolves policy_id from the Stage 10 mirror mapping, or clears it. Changing policy_id alone points the row at that v2 policy, which must exist in the same country, and clears legacy_policy_id. Supplied together, both are stored, and policy_id must be the mirror copy of legacy_policy_id when one exists. Rows maintained by the v1 saved-policy mirror return 409. + */ + patch: operations['patch_user_policy_route_v2_user_policies__association_id__patch']; + trace?: never; + }; + '/v2/households': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List immutable households */ + get: operations['get_households_v2_households_get']; + put?: never; + /** Create or find an immutable household */ + post: operations['create_household_v2_households_post']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/households/{household_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read one immutable household */ + get: operations['get_household_v2_households__household_id__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/policies': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List immutable policies */ + get: operations['get_policies_v2_policies_get']; + put?: never; + /** Create or find an immutable policy */ + post: operations['create_policy_v2_policies_post']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/policies/{policy_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Read one immutable policy */ + get: operations['get_policy_v2_policies__policy_id__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/tax-benefit-models': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List models for one PolicyEngine.py catalog */ + get: operations['list_models_v2_tax_benefit_models_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/tax-benefit-models/by-country/{country_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a country model and selected PolicyEngine.py version */ + get: operations['get_model_by_country_v2_tax_benefit_models_by_country__country_id__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/tax-benefit-models/{model_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one model from a selected PolicyEngine.py catalog */ + get: operations['get_model_v2_tax_benefit_models__model_id__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/tax-benefit-model-versions': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List selected PolicyEngine.py model versions */ + get: operations['list_model_versions_v2_tax_benefit_model_versions_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/tax-benefit-model-versions/{version_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one selected PolicyEngine.py model version */ + get: operations['get_model_version_v2_tax_benefit_model_versions__version_id__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/variables': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List variables from a selected PolicyEngine.py catalog */ + get: operations['list_variables_v2_variables_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/variables/{variable_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one variable from a selected PolicyEngine.py catalog */ + get: operations['get_variable_v2_variables__variable_id__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/parameters': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List parameters from a selected PolicyEngine.py catalog */ + get: operations['list_parameters_v2_parameters_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/parameters/children': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List one level of a catalog version's parameter tree */ + get: operations['list_parameter_children_v2_parameters_children_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/parameters/{parameter_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one parameter from a selected PolicyEngine.py catalog */ + get: operations['get_parameter_v2_parameters__parameter_id__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/parameter-values': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List canonical values from a selected PolicyEngine.py catalog */ + get: operations['list_parameter_values_v2_parameter_values_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/parameter-values/{value_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one canonical value from a selected PolicyEngine.py catalog */ + get: operations['get_parameter_value_v2_parameter_values__value_id__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/datasets': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List logical inputs from a selected PolicyEngine.py catalog */ + get: operations['list_datasets_v2_datasets_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/datasets/{dataset_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one logical input from a selected PolicyEngine.py catalog */ + get: operations['get_dataset_v2_datasets__dataset_id__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/regions': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List regions from a selected PolicyEngine.py catalog */ + get: operations['list_regions_v2_regions_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/regions/by-code/{region_code}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one region by code from a selected catalog */ + get: operations['get_region_by_code_v2_regions_by_code__region_code__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/regions/{region_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one region from a selected PolicyEngine.py catalog */ + get: operations['get_region_v2_regions__region_id__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/economy-options': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get compact economy-selection options from a selected catalog */ + get: operations['get_economy_options_v2_economy_options_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/model-catalog': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the small parts of one PolicyEngine.py catalog version + * @description Returns the selected catalog version's model, model version, entities, basic inputs, regions, logical input datasets and economy choices, which are empty lists for a catalog without regions. The result names the selected PolicyEngine.py version, so a client can request the version's other parts by that exact version; a response for a named version may be reused for a day. + */ + get: operations['get_model_catalog_v2_model_catalog_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/model-catalog/variables': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get every variable of one PolicyEngine.py catalog version + * @description Returns all of the selected catalog version's variables in name order, in one response. Where a variable's adds or subtracts list comes from a parameter, part_parameter_values carries that parameter's current-law values by date, so a client can pick the list for any year. + */ + get: operations['get_catalog_variables_v2_model_catalog_variables_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/model-catalog/parameters': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get several parameters of one catalog version by name + * @description Returns the named parameters (repeat name for each, at most 100) in name order, each with its economy and household flags and its current-law values by date, plus the stored labels of their ancestor nodes for breadcrumbs and the requested names the version has no parameter for. A client showing or editing a reform loads all of its parameters in this one request. + */ + get: operations['get_catalog_parameters_v2_model_catalog_parameters_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/parameters/search-file': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a catalog version's parameter search file */ + get: operations['get_parameter_search_file_v2_parameters_search_file_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * AccountPatchRequest + * @description Explicitly supplied account presentation fields. + */ + AccountPatchRequest: { + /** First Name */ + first_name?: string | null; + /** Last Name */ + last_name?: string | null; + /** Primary Country */ + primary_country?: ('us' | 'uk') | null; + }; + FiniteJsonValue: + | components['schemas']['FiniteJsonValue'][] + | { + [key: string]: components['schemas']['FiniteJsonValue']; + } + | string + | boolean + | number + | null; + /** + * HealthResponse + * @description Health-check result showing that the API is up and serving requests. + */ + HealthResponse: { + /** + * Status + * @constant + */ + status: 'healthy'; + }; + /** + * HouseholdCreateRequest + * @description Native immutable household content with no presentation or model fields. + */ + HouseholdCreateRequest: { + /** + * Country Id + * @description Supported PolicyEngine country ID + * @enum {string} + */ + country_id: 'us' | 'uk'; + /** + * Default Year + * @description Default year for values without explicit period keys + */ + default_year: number; + /** Household Data */ + household_data: + | components['schemas']['USHouseholdDocument-Input'] + | components['schemas']['UKHouseholdDocument']; + }; + /** + * HouseholdDetailResponse + * @description Success response holding one household. + */ + HouseholdDetailResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['HouseholdDetailResult']; + }; + /** + * HouseholdDetailResult + * @description Result holding one household in ``item``. + */ + HouseholdDetailResult: { + item: components['schemas']['HouseholdItem']; + }; + /** + * HouseholdEntityRecord + * @description One group entity in a household, such as a tax unit or a benefit unit. + * + * ``id`` is unique within its collection and is what people's ``memberships`` + * refer to. ``values`` maps variable names to values, and ``source_name`` + * optionally keeps the entity's original name, such as its key in a v1 household. + */ + HouseholdEntityRecord: { + /** Id */ + id: string; + /** Source Name */ + source_name?: string | null; + /** Values */ + values: { + [key: string]: components['schemas']['JsonValue']; + }; + }; + /** + * HouseholdItem + * @description A stored household. + * + * Households are immutable and deduplicated: creating one with identical + * content returns the existing household and its ``id``. + */ + HouseholdItem: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * Country Id + * @description Supported PolicyEngine country ID + * @enum {string} + */ + country_id: 'us' | 'uk'; + /** Default Year */ + default_year: number | null; + /** Household Data */ + household_data: + | components['schemas']['USHouseholdDocument-Output'] + | components['schemas']['UKHouseholdDocument']; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; + /** + * HouseholdPageResponse + * @description Success response holding one page of households. + */ + HouseholdPageResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['HouseholdPageResult']; + }; + /** + * HouseholdPageResult + * @description One page of households, oldest first. + * + * ``offset`` and ``limit`` echo the request; when ``has_more`` is true, ask + * for the next page with ``offset + limit``. + */ + HouseholdPageResult: { + /** Items */ + items: components['schemas']['HouseholdItem'][]; + /** Offset */ + offset: number; + /** Limit */ + limit: number; + /** Has More */ + has_more: boolean; + }; + /** + * HouseholdPersonRecord + * @description One person in a household. + * + * ``memberships`` maps each of the country's entity collections to the ``id`` + * of the group the person belongs to, and must name every collection. + */ + HouseholdPersonRecord: { + /** Id */ + id: string; + /** Source Name */ + source_name?: string | null; + /** Values */ + values: { + [key: string]: components['schemas']['JsonValue']; + }; + /** Memberships */ + memberships: { + [key: string]: string; + }; + }; + JsonValue: unknown; + /** + * MeDetailResponse + * @description Success response holding the current user. + */ + MeDetailResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MeDetailResult']; + }; + /** + * MeDetailResult + * @description Result holding the current user in ``item``. + */ + MeDetailResult: { + item: components['schemas']['MeItem']; + }; + /** + * MeExportResponse + * @description Success response holding the current user's export. + */ + MeExportResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MeExportResult']; + }; + /** + * MeExportResult + * @description The current user and every policy, household, simulation and report it saved. + * + * The export covers every country, not only the requested one. + */ + MeExportResult: { + user: components['schemas']['MeItem'] | null; + /** Policies */ + policies: components['schemas']['UserPolicyItem'][]; + /** Households */ + households: components['schemas']['UserHouseholdItem'][]; + /** Simulations */ + simulations: components['schemas']['UserSimulationItem'][]; + /** Reports */ + reports: components['schemas']['UserReportItem'][]; + }; + /** + * MeItem + * @description The current user: a signed-in account or a browser's unauthenticated user. + * + * ``user_type`` says which. An unauthenticated user has no primary country, and + * only a signed-in user can set its names or primary country. + */ + MeItem: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * User Type + * @enum {string} + */ + user_type: 'unauthenticated' | 'standard'; + /** Primary Country */ + primary_country: ('us' | 'uk') | null; + /** First Name */ + first_name: string | null; + /** Last Name */ + last_name: string | null; + /** Email */ + email: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + }; + /** + * MeUpgradeResponse + * @description Success response holding the signed-in account after an upgrade. + */ + MeUpgradeResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MeUpgradeResult']; + }; + /** + * MeUpgradeResult + * @description The signed-in account after a browser's unauthenticated user moved into it. + * + * ``outcome`` is ``converted`` when the unauthenticated user became the account + * in place, ``merged`` when its records moved to an existing account, and + * ``none`` when there was no unauthenticated user to move. + */ + MeUpgradeResult: { + item: components['schemas']['MeItem']; + /** + * Outcome + * @enum {string} + */ + outcome: 'converted' | 'merged' | 'none'; + }; + /** + * MetadataCanonicalParameterValue + * @description One current-law value of a parameter. + * + * The value applies from start_date until end_date, or indefinitely when + * end_date is null. + */ + MetadataCanonicalParameterValue: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * Parameter Id + * Format: uuid + */ + parameter_id: string; + value: components['schemas']['JsonValue']; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + }; + /** + * MetadataCatalogParameter + * @description A parameter with its current-law values in start-date order. + */ + MetadataCatalogParameter: { + /** + * Id + * Format: uuid + */ + id: string; + /** Name */ + name: string; + /** Label */ + label: string | null; + /** Description */ + description: string | null; + /** Data Type */ + data_type: string | null; + /** Unit */ + unit: string | null; + /** Economy */ + economy?: boolean | null; + /** Household */ + household?: boolean | null; + /** Values */ + values: components['schemas']['MetadataDatedValue'][]; + }; + /** + * MetadataCatalogParametersResponse + * @description Success response holding parameters fetched by name, with their values. + */ + MetadataCatalogParametersResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataCatalogParametersResult']; + }; + /** + * MetadataCatalogParametersResult + * @description Requested parameters of one catalog version, in name order. + * + * node_labels maps each ancestor of a requested parameter that the version + * stores as a parameter node to the node's stored label (null when it has + * none), for breadcrumbs; a bracket index such as ``rates[0]`` counts as a + * child of its schedule ``rates``. missing_names lists requested names the + * version has no parameter for. + */ + MetadataCatalogParametersResult: { + /** Policyengine Version */ + policyengine_version: string; + /** Catalog Format */ + catalog_format: number | null; + /** Parameters */ + parameters: components['schemas']['MetadataCatalogParameter'][]; + /** Node Labels */ + node_labels: { + [key: string]: string | null; + }; + /** Missing Names */ + missing_names: string[]; + }; + /** + * MetadataCatalogVariablesResponse + * @description Success response holding every variable of one catalog version. + */ + MetadataCatalogVariablesResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataCatalogVariablesResult']; + }; + /** + * MetadataCatalogVariablesResult + * @description Every variable of one catalog version, in name order. + * + * part_parameter_values has an entry for each parameter path a variable's + * adds_parameter_path or subtracts_parameter_path names: that parameter's + * current-law values in start-date order, or an empty list when the + * catalog has no such parameter. + */ + MetadataCatalogVariablesResult: { + /** Policyengine Version */ + policyengine_version: string; + /** Catalog Format */ + catalog_format: number | null; + /** Variables */ + variables: components['schemas']['MetadataVariable'][]; + /** Part Parameter Values */ + part_parameter_values: { + [key: string]: components['schemas']['MetadataDatedValue'][]; + }; + }; + /** + * MetadataDataset + * @description A named input dataset available to the selected model version. + * + * These datasets are logical inputs, so storage_path is always null and + * is_output_dataset is always false. + */ + MetadataDataset: { + /** + * Id + * Format: uuid + */ + id: string; + /** Name */ + name: string; + /** Description */ + description: string | null; + /** Year */ + year: number; + /** Storage Path */ + storage_path?: null; + /** + * Is Output Dataset + * @default false + * @constant + */ + is_output_dataset: false; + }; + /** + * MetadataDatasetDetailResponse + * @description Success response holding one dataset. + */ + MetadataDatasetDetailResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataDetailResult_MetadataDataset_']; + }; + /** + * MetadataDatasetOption + * @description The national dataset, offered as the default for an economy calculation. + */ + MetadataDatasetOption: { + /** Name */ + name: string; + /** Label */ + label: string; + /** + * Default + * @default true + * @constant + */ + default: true; + }; + /** + * MetadataDatasetPageResponse + * @description Success response holding one page of datasets. + */ + MetadataDatasetPageResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataPageResult_MetadataDataset_']; + }; + /** + * MetadataDatedValue + * @description A current-law parameter value from start_date until end_date. + * + * A null end_date means the value applies until a later value starts, or + * indefinitely. + */ + MetadataDatedValue: { + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + value: components['schemas']['JsonValue']; + }; + /** MetadataDetailResult[MetadataCanonicalParameterValue] */ + MetadataDetailResult_MetadataCanonicalParameterValue_: { + /** Policyengine Version */ + policyengine_version: string; + item: components['schemas']['MetadataCanonicalParameterValue']; + }; + /** MetadataDetailResult[MetadataDataset] */ + MetadataDetailResult_MetadataDataset_: { + /** Policyengine Version */ + policyengine_version: string; + item: components['schemas']['MetadataDataset']; + }; + /** MetadataDetailResult[MetadataModelVersionDetail] */ + MetadataDetailResult_MetadataModelVersionDetail_: { + /** Policyengine Version */ + policyengine_version: string; + item: components['schemas']['MetadataModelVersionDetail']; + }; + /** MetadataDetailResult[MetadataModel] */ + MetadataDetailResult_MetadataModel_: { + /** Policyengine Version */ + policyengine_version: string; + item: components['schemas']['MetadataModel']; + }; + /** MetadataDetailResult[MetadataParameterSummary] */ + MetadataDetailResult_MetadataParameterSummary_: { + /** Policyengine Version */ + policyengine_version: string; + item: components['schemas']['MetadataParameterSummary']; + }; + /** MetadataDetailResult[MetadataRegion] */ + MetadataDetailResult_MetadataRegion_: { + /** Policyengine Version */ + policyengine_version: string; + item: components['schemas']['MetadataRegion']; + }; + /** MetadataDetailResult[MetadataVariable] */ + MetadataDetailResult_MetadataVariable_: { + /** Policyengine Version */ + policyengine_version: string; + item: components['schemas']['MetadataVariable']; + }; + /** + * MetadataEconomyOptions + * @description The region, year and dataset choices for an economy calculation. + * + * Every list is empty for a catalog without regions, such as a + * household-only country's, which offers no economy calculation. + */ + MetadataEconomyOptions: { + /** Region */ + region: components['schemas']['MetadataRegionOption'][]; + /** Time Period */ + time_period: components['schemas']['MetadataTimePeriodOption'][]; + /** Datasets */ + datasets: components['schemas']['MetadataDatasetOption'][]; + }; + /** + * MetadataEconomyOptionsResponse + * @description Success response holding the options for configuring an economy comparison. + */ + MetadataEconomyOptionsResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataEconomyOptionsResult']; + }; + /** + * MetadataEconomyOptionsResult + * @description The choices for configuring an economy calculation. + * + * It lists the selectable regions, years and datasets, and the id of the + * current-law policy to use as a baseline. + */ + MetadataEconomyOptionsResult: { + /** Policyengine Version */ + policyengine_version: string; + /** Current Law Id */ + current_law_id: number; + /** Region */ + region: components['schemas']['MetadataRegionOption'][]; + /** Time Period */ + time_period: components['schemas']['MetadataTimePeriodOption'][]; + /** Datasets */ + datasets: components['schemas']['MetadataDatasetOption'][]; + }; + /** + * MetadataEntity + * @description A kind of member a household holds, such as a person or a tax unit. + * + * plural is the key the household's members of this kind are listed under, + * and is_person marks the entity every household input about a person uses. + */ + MetadataEntity: { + /** Key */ + key: string; + /** Plural */ + plural: string; + /** Is Person */ + is_person: boolean; + }; + /** + * MetadataEnumValue + * @description One value of an enum variable: its stored name and its display label. + * + * The label keeps the type the country package declares it with. + */ + MetadataEnumValue: { + /** Value */ + value: string; + /** Label */ + label: string | number | boolean | null; + }; + /** + * MetadataModel + * @description A country's tax-benefit model in the selected PolicyEngine.py catalog. + */ + MetadataModel: { + /** + * Id + * Format: uuid + */ + id: string; + /** Name */ + name: string; + /** Description */ + description: string | null; + }; + /** + * MetadataModelCatalogResponse + * @description Success response holding the small parts of one catalog version. + */ + MetadataModelCatalogResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataModelCatalogResult']; + }; + /** + * MetadataModelCatalogResult + * @description The small parts of one catalog version, read when a client starts. + * + * policyengine_version names the selected catalog version, so a client can + * request its other parts for that exact version. catalog_format is null, + * and entities and basic_inputs are empty, for a version published before + * catalog format 2. Variables, parameters and their values are served by + * their own routes. + */ + MetadataModelCatalogResult: { + /** Policyengine Version */ + policyengine_version: string; + /** Catalog Format */ + catalog_format: number | null; + model: components['schemas']['MetadataModel']; + model_version: components['schemas']['MetadataModelVersionDetail']; + /** Entities */ + entities: components['schemas']['MetadataEntity'][]; + /** Basic Inputs */ + basic_inputs: string[]; + /** Regions */ + regions: components['schemas']['MetadataRegion'][]; + /** Datasets */ + datasets: components['schemas']['MetadataDataset'][]; + economy_options: components['schemas']['MetadataEconomyOptions']; + }; + /** + * MetadataModelDetailResponse + * @description Success response holding one tax-benefit model. + */ + MetadataModelDetailResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataDetailResult_MetadataModel_']; + }; + /** + * MetadataModelPageResponse + * @description Success response holding a page of the selected catalog's tax-benefit models. + * + * A catalog has one model per country, so only the first page has an item. + */ + MetadataModelPageResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataPageResult_MetadataModel_']; + }; + /** + * MetadataModelSelectionResponse + * @description Success response holding a country's model and its selected model version. + */ + MetadataModelSelectionResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataModelSelectionResult']; + }; + /** + * MetadataModelSelectionResult + * @description A country's model together with the model version selected for the request. + */ + MetadataModelSelectionResult: { + /** Policyengine Version */ + policyengine_version: string; + model: components['schemas']['MetadataModel']; + model_version: components['schemas']['MetadataModelVersionDetail']; + }; + /** + * MetadataModelVersionDetail + * @description A model version, with its current-law policy id and the years offered. + */ + MetadataModelVersionDetail: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * Model Id + * Format: uuid + */ + model_id: string; + /** Version */ + version: string; + /** Description */ + description: string | null; + /** Current Law Id */ + current_law_id: number; + /** Metadata Time Periods */ + metadata_time_periods: number[]; + }; + /** + * MetadataModelVersionDetailResponse + * @description Success response holding one model version. + */ + MetadataModelVersionDetailResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataDetailResult_MetadataModelVersionDetail_']; + }; + /** + * MetadataModelVersionPageResponse + * @description Success response holding a page of the selected catalog's model versions. + * + * A catalog has one model version, so only the first page has an item. + */ + MetadataModelVersionPageResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataPageResult_MetadataModelVersionDetail_']; + }; + /** MetadataPageResult[MetadataCanonicalParameterValue] */ + MetadataPageResult_MetadataCanonicalParameterValue_: { + /** Policyengine Version */ + policyengine_version: string; + /** Items */ + items: components['schemas']['MetadataCanonicalParameterValue'][]; + /** Offset */ + offset: number; + /** Limit */ + limit: number; + /** Has More */ + has_more: boolean; + }; + /** MetadataPageResult[MetadataDataset] */ + MetadataPageResult_MetadataDataset_: { + /** Policyengine Version */ + policyengine_version: string; + /** Items */ + items: components['schemas']['MetadataDataset'][]; + /** Offset */ + offset: number; + /** Limit */ + limit: number; + /** Has More */ + has_more: boolean; + }; + /** MetadataPageResult[MetadataModelVersionDetail] */ + MetadataPageResult_MetadataModelVersionDetail_: { + /** Policyengine Version */ + policyengine_version: string; + /** Items */ + items: components['schemas']['MetadataModelVersionDetail'][]; + /** Offset */ + offset: number; + /** Limit */ + limit: number; + /** Has More */ + has_more: boolean; + }; + /** MetadataPageResult[MetadataModel] */ + MetadataPageResult_MetadataModel_: { + /** Policyengine Version */ + policyengine_version: string; + /** Items */ + items: components['schemas']['MetadataModel'][]; + /** Offset */ + offset: number; + /** Limit */ + limit: number; + /** Has More */ + has_more: boolean; + }; + /** MetadataPageResult[MetadataParameterChild] */ + MetadataPageResult_MetadataParameterChild_: { + /** Policyengine Version */ + policyengine_version: string; + /** Items */ + items: components['schemas']['MetadataParameterChild'][]; + /** Offset */ + offset: number; + /** Limit */ + limit: number; + /** Has More */ + has_more: boolean; + }; + /** MetadataPageResult[MetadataParameterSummary] */ + MetadataPageResult_MetadataParameterSummary_: { + /** Policyengine Version */ + policyengine_version: string; + /** Items */ + items: components['schemas']['MetadataParameterSummary'][]; + /** Offset */ + offset: number; + /** Limit */ + limit: number; + /** Has More */ + has_more: boolean; + }; + /** MetadataPageResult[MetadataRegion] */ + MetadataPageResult_MetadataRegion_: { + /** Policyengine Version */ + policyengine_version: string; + /** Items */ + items: components['schemas']['MetadataRegion'][]; + /** Offset */ + offset: number; + /** Limit */ + limit: number; + /** Has More */ + has_more: boolean; + }; + /** MetadataPageResult[MetadataVariable] */ + MetadataPageResult_MetadataVariable_: { + /** Policyengine Version */ + policyengine_version: string; + /** Items */ + items: components['schemas']['MetadataVariable'][]; + /** Offset */ + offset: number; + /** Limit */ + limit: number; + /** Has More */ + has_more: boolean; + }; + /** + * MetadataParameterChild + * @description A direct child in the parameter tree. + * + * A node has a child_count; a parameter leaf carries the parameter's summary. + */ + MetadataParameterChild: { + /** Path */ + path: string; + /** Label */ + label: string; + /** + * Type + * @enum {string} + */ + type: 'node' | 'parameter'; + /** Child Count */ + child_count?: number | null; + parameter?: components['schemas']['MetadataParameterSummary'] | null; + }; + /** + * MetadataParameterChildPageResponse + * @description Success response holding one page of a parameter path's direct children. + */ + MetadataParameterChildPageResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataPageResult_MetadataParameterChild_']; + }; + /** + * MetadataParameterDetailResponse + * @description Success response holding one parameter. + */ + MetadataParameterDetailResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataDetailResult_MetadataParameterSummary_']; + }; + /** + * MetadataParameterPageResponse + * @description Success response holding one page of parameters. + */ + MetadataParameterPageResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataPageResult_MetadataParameterSummary_']; + }; + /** + * MetadataParameterSummary + * @description A policy parameter, described without its values. + * + * Its current-law values are listed by GET /v2/parameter-values. economy and + * household say whether a reform of an economy or of a household may change + * it; they are null in versions published before catalog format 2. + */ + MetadataParameterSummary: { + /** + * Id + * Format: uuid + */ + id: string; + /** Name */ + name: string; + /** Label */ + label: string | null; + /** Description */ + description: string | null; + /** Data Type */ + data_type: string | null; + /** Unit */ + unit: string | null; + /** Economy */ + economy?: boolean | null; + /** Household */ + household?: boolean | null; + }; + /** + * MetadataParameterValueDetailResponse + * @description Success response holding one parameter value. + */ + MetadataParameterValueDetailResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataDetailResult_MetadataCanonicalParameterValue_']; + }; + /** + * MetadataParameterValuePageResponse + * @description Success response holding one page of parameter values. + */ + MetadataParameterValuePageResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataPageResult_MetadataCanonicalParameterValue_']; + }; + /** + * MetadataRegion + * @description A region the model can simulate, such as a country, state or constituency. + * + * When requires_filter is true, filter_strategy says how the region selects + * households of its default dataset. With "row_filter", it keeps the + * households whose filter_field column equals filter_value (or is in it, + * when filter_value is a JSON list). With "weight_replacement", it keeps + * every household and gives each the weight in row filter_value of the + * weight matrix that filter_field names in the certified data release. + */ + MetadataRegion: { + /** + * Id + * Format: uuid + */ + id: string; + /** Code */ + code: string; + /** Label */ + label: string; + region_type: components['schemas']['MetadataRegionType']; + /** Requires Filter */ + requires_filter: boolean; + /** Filter Field */ + filter_field: string | null; + /** Filter Value */ + filter_value: string | null; + /** Filter Strategy */ + filter_strategy: string | null; + /** Parent Code */ + parent_code: string | null; + /** State Code */ + state_code: string | null; + /** State Name */ + state_name: string | null; + /** + * Default Dataset Id + * Format: uuid + */ + default_dataset_id: string; + }; + /** + * MetadataRegionDetailResponse + * @description Success response holding one region. + */ + MetadataRegionDetailResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataDetailResult_MetadataRegion_']; + }; + /** + * MetadataRegionOption + * @description A region that can be chosen for an economy calculation; name is its code. + */ + MetadataRegionOption: { + /** Name */ + name: string; + /** Label */ + label: string; + type: components['schemas']['MetadataRegionType']; + }; + /** + * MetadataRegionPageResponse + * @description Success response holding one page of regions. + */ + MetadataRegionPageResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataPageResult_MetadataRegion_']; + }; + /** + * MetadataRegionType + * @description The geographic level of a region. + * @enum {string} + */ + MetadataRegionType: + | 'national' + | 'country' + | 'state' + | 'congressional_district' + | 'constituency' + | 'local_authority' + | 'city' + | 'place'; + /** + * MetadataTimePeriodOption + * @description A year that can be chosen for an economy calculation. + */ + MetadataTimePeriodOption: { + /** Name */ + name: number; + /** Label */ + label: string; + }; + /** + * MetadataVariable + * @description A variable the tax-benefit model computes or accepts as input. + * + * adds and subtracts, when present, list the variables summed into or + * subtracted from this one. When the model takes that list from a + * parameter, adds_parameter_path or subtracts_parameter_path names the + * parameter, whose dated values give the list for each year (adds and + * subtracts hold its value on 2025-01-01). The catalog format 2 fields + * (is_input_variable, hidden_input, module_name, the two parameter paths + * and enum_values) are null in versions published before that format. + */ + MetadataVariable: { + /** + * Id + * Format: uuid + */ + id: string; + /** Name */ + name: string; + /** Label */ + label: string | null; + /** Entity */ + entity: string; + /** Description */ + description: string | null; + /** Data Type */ + data_type: string | null; + /** Possible Values */ + possible_values: string[] | null; + default_value: components['schemas']['JsonValue']; + /** Adds */ + adds: string[] | null; + /** Subtracts */ + subtracts: string[] | null; + /** Is Input Variable */ + is_input_variable?: boolean | null; + /** Hidden Input */ + hidden_input?: boolean | null; + /** Module Name */ + module_name?: string | null; + /** Adds Parameter Path */ + adds_parameter_path?: string | null; + /** Subtracts Parameter Path */ + subtracts_parameter_path?: string | null; + /** Enum Values */ + enum_values?: components['schemas']['MetadataEnumValue'][] | null; + }; + /** + * MetadataVariableDetailResponse + * @description Success response holding one variable. + */ + MetadataVariableDetailResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataDetailResult_MetadataVariable_']; + }; + /** + * MetadataVariablePageResponse + * @description Success response holding one page of variables. + */ + MetadataVariablePageResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['MetadataPageResult_MetadataVariable_']; + }; + /** + * PolicyCreateRequest + * @description Native body containing immutable policy content only. + */ + PolicyCreateRequest: { + /** + * Country Id + * @description Supported PolicyEngine country ID + * @enum {string} + */ + country_id: 'us' | 'uk'; + /** + * Tax Benefit Model Id + * Format: uuid + */ + tax_benefit_model_id: string; + /** Parameter Values */ + parameter_values: components['schemas']['PolicyParameterValueInput'][]; + }; + /** + * PolicyDetailResponse + * @description Success response holding one policy. + */ + PolicyDetailResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['PolicyDetailResult']; + }; + /** + * PolicyDetailResult + * @description Result holding one policy in ``item``. + */ + PolicyDetailResult: { + item: components['schemas']['PolicyItem']; + }; + /** + * PolicyItem + * @description A stored policy: parameter values for one tax-benefit model. + * + * Policies are immutable and deduplicated: creating one with identical + * content returns the existing policy and its ``id``. + */ + PolicyItem: { + /** + * Id + * Format: uuid + */ + id: string; + /** Country Id */ + country_id: string; + /** + * Tax Benefit Model Id + * Format: uuid + */ + tax_benefit_model_id: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + /** Parameter Values */ + parameter_values: components['schemas']['PolicyParameterValueItem'][]; + }; + /** + * PolicyPageResponse + * @description Success response holding one page of policies. + */ + PolicyPageResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['PolicyPageResult']; + }; + /** + * PolicyPageResult + * @description One page of policies, oldest first. + * + * ``offset`` and ``limit`` echo the request; when ``has_more`` is true, ask + * for the next page with ``offset + limit``. + */ + PolicyPageResult: { + /** Items */ + items: components['schemas']['PolicyItem'][]; + /** Offset */ + offset: number; + /** Limit */ + limit: number; + /** Has More */ + has_more: boolean; + }; + /** + * PolicyParameterValueInput + * @description One normalized effective value for a catalog parameter UUID. + */ + PolicyParameterValueInput: { + /** + * Parameter Id + * Format: uuid + */ + parameter_id: string; + value: components['schemas']['JsonValue']; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date?: string | null; + }; + /** + * PolicyParameterValueItem + * @description One value a policy sets for a parameter from ``start_date``. + * + * ``parameter_name`` is the parameter's path in the model, and a null + * ``end_date`` means the value has no end. + */ + PolicyParameterValueItem: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * Parameter Id + * Format: uuid + */ + parameter_id: string; + /** Parameter Name */ + parameter_name: string; + value: components['schemas']['JsonValue']; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** End Date */ + end_date: string | null; + }; + /** + * PolicyProvenance + * @description Where a v3 reform came from. + */ + PolicyProvenance: { + /** + * Source + * @enum {string} + */ + source: 'manual' | 'chat' | 'bill' | 'tool'; + /** Ref */ + ref?: string | null; + /** Confidence */ + confidence?: ('exact' | 'approximated' | 'partial') | null; + }; + /** + * PolicyProvenanceItem + * @description Stored v3 reform provenance as responses publish it. + * + * It mirrors the contract without the input bounds and ignores unknown keys, + * so any stored row serializes. + */ + PolicyProvenanceItem: { + /** Source */ + source: string; + /** Ref */ + ref?: string | null; + /** Confidence */ + confidence?: string | null; + }; + /** + * ReportCalibration + * @description Calibration results for one geography, with at most 500 variable matches. + */ + ReportCalibration: { + /** Release Id */ + release_id?: string | null; + /** Geography */ + geography: string; + /** Reached Count */ + reached_count: number; + /** Matches */ + matches: components['schemas']['ReportCalibrationMatch'][]; + }; + /** + * ReportCalibrationItem + * @description Calibration results saved with a report, for one geography and data release. + */ + ReportCalibrationItem: { + /** Release Id */ + release_id?: string | null; + /** Geography */ + geography: string; + /** Reached Count */ + reached_count: number; + /** Matches */ + matches: components['schemas']['ReportCalibrationMatchItem'][]; + }; + /** + * ReportCalibrationMatch + * @description Calibration of one variable: how closely its targets are met. + * + * ``ring`` is ``primary``, ``mechanism`` or ``downstream``. + * ``mean_abs_relative_error`` is averaged over the variable's ``target_count`` + * targets, and ``worst`` is the target with the largest error. + */ + ReportCalibrationMatch: { + /** Variable */ + variable: string; + /** Depth */ + depth: number; + /** + * Ring + * @enum {string} + */ + ring: 'primary' | 'mechanism' | 'downstream'; + /** Target Count */ + target_count: number; + /** Mean Abs Relative Error */ + mean_abs_relative_error: number; + worst: components['schemas']['ReportCalibrationWorst']; + }; + /** + * ReportCalibrationMatchItem + * @description One variable's calibration match: its targets, mean error and worst target. + */ + ReportCalibrationMatchItem: { + /** Variable */ + variable: string; + /** Depth */ + depth: number; + /** Ring */ + ring: string; + /** Target Count */ + target_count: number; + /** Mean Abs Relative Error */ + mean_abs_relative_error: number; + worst: components['schemas']['ReportCalibrationWorstItem']; + }; + /** + * ReportCalibrationWorst + * @description The calibration target a match fits worst, with its source and geography. + */ + ReportCalibrationWorst: { + /** Name */ + name: string; + /** Source */ + source: string; + /** Geography */ + geography: string; + /** Relative Error */ + relative_error?: number | null; + }; + /** + * ReportCalibrationWorstItem + * @description The calibration target that one match fits worst, with its relative error. + */ + ReportCalibrationWorstItem: { + /** Name */ + name: string; + /** Source */ + source: string; + /** Geography */ + geography: string; + /** Relative Error */ + relative_error?: number | null; + }; + /** + * ReportProvision + * @description One provision of the report's reform: a parameter's baseline and reform values. + * + * ``path`` is the parameter path and ``breadcrumb`` its readable label, and + * ``intervals`` optionally gives a dated schedule. ``growth`` says how a value + * set from one year on moves in later years (``fixed``, ``current_law`` or an + * index's parameter path); the other ``growth_*`` fields say when it moves and + * how grown values round. They are absent for a hand-set schedule. + */ + ReportProvision: { + /** Path */ + path: string; + /** Breadcrumb */ + breadcrumb: string; + /** Unit */ + unit?: string | null; + baseline_value: components['schemas']['FiniteJsonValue']; + value: components['schemas']['FiniteJsonValue']; + /** Intervals */ + intervals?: components['schemas']['ReportProvisionInterval'][] | null; + /** Growth */ + growth?: string | null; + /** Growth Start */ + growth_start?: number | null; + /** Growth Base */ + growth_base?: number | null; + /** Growth Round To */ + growth_round_to?: number | null; + /** Growth Round */ + growth_round?: ('nearest' | 'down' | 'up') | null; + }; + /** + * ReportProvisionInterval + * @description One dated value in a provision's schedule, from ``start_date`` to ``end_date``. + * + * Dates are ``YYYY-MM-DD`` strings and ``value`` is any finite JSON value. + * ``parameter`` is optional, and is left out of the stored report when unset. + */ + ReportProvisionInterval: { + /** Parameter */ + parameter?: string | null; + /** Start Date */ + start_date: string; + /** End Date */ + end_date: string; + value: components['schemas']['FiniteJsonValue']; + }; + /** + * ReportProvisionIntervalItem + * @description A provision's value for the dates from ``start_date`` to ``end_date``. + */ + ReportProvisionIntervalItem: { + /** Parameter */ + parameter?: string | null; + /** Start Date */ + start_date: string; + /** End Date */ + end_date: string; + value: components['schemas']['JsonValue']; + }; + /** + * ReportProvisionItem + * @description One parameter the report's reform changes, with its baseline and reform values. + * + * ``intervals`` gives the reform's dated values when they vary over time. The + * ``growth`` keys, present only when set, say how a value set from one year on + * moves in later years and how grown values round. + */ + ReportProvisionItem: { + /** Path */ + path: string; + /** Breadcrumb */ + breadcrumb: string; + /** Unit */ + unit?: string | null; + baseline_value: components['schemas']['JsonValue']; + value: components['schemas']['JsonValue']; + /** Intervals */ + intervals?: components['schemas']['ReportProvisionIntervalItem'][] | null; + /** Growth */ + growth?: string | null; + /** Growth Start */ + growth_start?: number | null; + /** Growth Base */ + growth_base?: number | null; + /** Growth Round To */ + growth_round_to?: number | null; + /** Growth Round */ + growth_round?: string | null; + }; + /** + * ReportScorecard + * @description The programs on the report's validation scorecard, at most 200 names. + */ + ReportScorecard: { + /** Programs */ + programs: string[]; + }; + /** + * ReportScorecardItem + * @description The programs on the scorecard saved with a report's validation. + */ + ReportScorecardItem: { + /** Programs */ + programs: string[]; + }; + /** + * ReportValidation + * @description The validation snapshot saved with a report. + * + * ``calibration`` and ``scorecard`` are optional, and the stored snapshot keeps + * only the keys supplied. + */ + ReportValidation: { + /** Matched At */ + matched_at: string; + /** Map Model Version */ + map_model_version: string; + calibration?: components['schemas']['ReportCalibration'] | null; + scorecard?: components['schemas']['ReportScorecard'] | null; + }; + /** + * ReportValidationItem + * @description The validation snapshot saved with a report. + * + * It records when the report was matched and with which map model version, + * and optionally the calibration results and scorecard. + */ + ReportValidationItem: { + /** Matched At */ + matched_at: string; + /** Map Model Version */ + map_model_version: string; + calibration?: components['schemas']['ReportCalibrationItem'] | null; + scorecard?: components['schemas']['ReportScorecardItem'] | null; + }; + /** + * SPMSelection + * @description Select a forecast scenario and an explicit household SPM geography. + */ + SPMSelection: { + /** Forecast Content Sha256 */ + forecast_content_sha256?: string | null; + /** Scenario */ + scenario?: string | null; + /** + * Geography Kind + * @enum {string} + */ + geography_kind?: 'county' | 'national' | 'metro'; + /** Geography Id */ + geography_id?: string | null; + /** County Vintage */ + county_vintage?: string; + /** + * As Of + * Format: date + * @description ISO calendar date (YYYY-MM-DD); must satisfy the selected artifact's information date + */ + as_of?: string | null; + } & ( + | { + /** @enum {unknown} */ + geography_kind?: 'county' | 'national'; + } + | { + /** @enum {unknown} */ + geography_kind: 'metro'; + geography_id: string; + } + ); + /** + * SharedBaselineReformItem + * @description The saved reform a shared report was compared with, named but not linked. + */ + SharedBaselineReformItem: { + /** Name */ + name: string | null; + }; + /** + * SharedUserReportDetailResponse + * @description Success response holding one shared report. + */ + SharedUserReportDetailResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['SharedUserReportDetailResult']; + }; + /** + * SharedUserReportDetailResult + * @description Result holding one shared report in ``item``. + */ + SharedUserReportDetailResult: { + item: components['schemas']['SharedUserReportItem']; + }; + /** + * SharedUserReportItem + * @description A saved report as anyone holding its link reads it. + */ + SharedUserReportItem: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * Country Id + * @description Supported PolicyEngine country ID + * @enum {string} + */ + country_id: 'us' | 'uk'; + /** Legacy Report Id */ + legacy_report_id: number | null; + /** Name */ + name: string | null; + /** Source Note */ + source_note: string | null; + /** Provisions */ + provisions: components['schemas']['ReportProvisionItem'][] | null; + validation: components['schemas']['ReportValidationItem'] | null; + /** Budget Window Years */ + budget_window_years: number | null; + baseline_reform: components['schemas']['SharedBaselineReformItem'] | null; + /** Last Run At */ + last_run_at: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; + /** + * UKHouseholdDocument + * @description UK household content: people, households and benefit units. + */ + UKHouseholdDocument: { + /** People */ + people: components['schemas']['HouseholdPersonRecord'][]; + /** Household */ + household: components['schemas']['HouseholdEntityRecord'][]; + /** Benunit */ + benunit: components['schemas']['HouseholdEntityRecord'][]; + }; + /** + * USHouseholdDocument + * @description US household content: people and the US group entities they belong to. + * + * ``spm`` optionally selects an SPM forecast scenario and geography; omit it + * rather than sending null. + */ + 'USHouseholdDocument-Input': { + /** People */ + people: components['schemas']['HouseholdPersonRecord'][]; + /** Household */ + household: components['schemas']['HouseholdEntityRecord'][]; + /** Family */ + family: components['schemas']['HouseholdEntityRecord'][]; + /** Tax Unit */ + tax_unit: components['schemas']['HouseholdEntityRecord'][]; + /** Spm Unit */ + spm_unit: components['schemas']['HouseholdEntityRecord'][]; + /** Marital Unit */ + marital_unit: components['schemas']['HouseholdEntityRecord'][]; + /** Spm */ + spm?: components['schemas']['SPMSelection']; + }; + 'USHouseholdDocument-Output': { + [key: string]: unknown; + }; + /** + * UserHouseholdCreateRequest + * @description Association fields; the owner is taken from the request credential. + */ + UserHouseholdCreateRequest: { + /** + * Country Id + * @description Supported PolicyEngine country ID + * @enum {string} + */ + country_id: 'us' | 'uk'; + /** Household Id */ + household_id?: string | null; + /** Legacy Household Id */ + legacy_household_id?: number | null; + /** Name */ + name?: string | null; + /** Description */ + description?: string | null; + }; + /** + * UserHouseholdDetailResponse + * @description Success response holding one user-household association. + */ + UserHouseholdDetailResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['UserHouseholdDetailResult']; + }; + /** + * UserHouseholdDetailResult + * @description Result holding one user-household association in ``item``. + */ + UserHouseholdDetailResult: { + item: components['schemas']['UserHouseholdItem']; + }; + /** + * UserHouseholdItem + * @description A household saved by a v2 user, with the user's name and description for it. + * + * Unlike households, associations can be renamed, pointed at another + * household of the same country, or deleted. + */ + UserHouseholdItem: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * Country Id + * @description Supported PolicyEngine country ID + * @enum {string} + */ + country_id: 'us' | 'uk'; + /** + * User Id + * Format: uuid + */ + user_id: string; + /** Household Id */ + household_id: string | null; + /** Legacy Household Id */ + legacy_household_id: number | null; + /** Name */ + name: string | null; + /** Description */ + description: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; + /** + * UserHouseholdPageResponse + * @description Success response holding one page of user-household associations. + */ + UserHouseholdPageResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['UserHouseholdPageResult']; + }; + /** + * UserHouseholdPageResult + * @description One page of user-household associations, oldest first. + * + * ``offset`` and ``limit`` echo the request; when ``has_more`` is true, ask + * for the next page with ``offset + limit``. + */ + UserHouseholdPageResult: { + /** Items */ + items: components['schemas']['UserHouseholdItem'][]; + /** Offset */ + offset: number; + /** Limit */ + limit: number; + /** Has More */ + has_more: boolean; + }; + /** + * UserHouseholdPatchRequest + * @description Explicitly supplied mutable link or presentation fields. + */ + UserHouseholdPatchRequest: { + /** Household Id */ + household_id?: string | null; + /** Legacy Household Id */ + legacy_household_id?: number | null; + /** Name */ + name?: string | null; + /** Description */ + description?: string | null; + }; + /** + * UserPolicyCreateRequest + * @description Association fields; the owner is taken from the request credential. + */ + UserPolicyCreateRequest: { + /** + * Country Id + * @description Supported PolicyEngine country ID + * @enum {string} + */ + country_id: 'us' | 'uk'; + /** Policy Id */ + policy_id?: string | null; + /** Legacy Policy Id */ + legacy_policy_id?: number | null; + /** Name */ + name?: string | null; + /** Description */ + description?: string | null; + /** Baseline */ + baseline?: ('current-law' | 'current-policy') | null; + provenance?: components['schemas']['PolicyProvenance'] | null; + }; + /** + * UserPolicyDetailResponse + * @description Success response holding one user-policy association. + */ + UserPolicyDetailResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['UserPolicyDetailResult']; + }; + /** + * UserPolicyDetailResult + * @description Result holding one user-policy association in ``item``. + */ + UserPolicyDetailResult: { + item: components['schemas']['UserPolicyItem']; + }; + /** + * UserPolicyItem + * @description A policy saved by a v2 user, with the user's name and description for it. + * + * Unlike policies, associations can be renamed or deleted. + */ + UserPolicyItem: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * Country Id + * @description Supported PolicyEngine country ID + * @enum {string} + */ + country_id: 'us' | 'uk'; + /** + * User Id + * Format: uuid + */ + user_id: string; + /** Policy Id */ + policy_id: string | null; + /** Legacy Policy Id */ + legacy_policy_id: number | null; + /** Name */ + name: string | null; + /** Description */ + description: string | null; + /** Baseline */ + baseline: ('current-law' | 'current-policy') | null; + provenance: components['schemas']['PolicyProvenanceItem'] | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; + /** + * UserPolicyPageResponse + * @description Success response holding one page of user-policy associations. + */ + UserPolicyPageResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['UserPolicyPageResult']; + }; + /** + * UserPolicyPageResult + * @description One page of user-policy associations, oldest first. + * + * ``offset`` and ``limit`` echo the request; when ``has_more`` is true, ask + * for the next page with ``offset + limit``. + */ + UserPolicyPageResult: { + /** Items */ + items: components['schemas']['UserPolicyItem'][]; + /** Offset */ + offset: number; + /** Limit */ + limit: number; + /** Has More */ + has_more: boolean; + }; + /** + * UserPolicyPatchRequest + * @description Explicitly supplied mutable link or presentation fields. + */ + UserPolicyPatchRequest: { + /** Policy Id */ + policy_id?: string | null; + /** Legacy Policy Id */ + legacy_policy_id?: number | null; + /** Name */ + name?: string | null; + /** Description */ + description?: string | null; + /** Baseline */ + baseline?: ('current-law' | 'current-policy') | null; + provenance?: components['schemas']['PolicyProvenance'] | null; + }; + /** + * UserReportCreateRequest + * @description Association fields; the owner is taken from the request credential. + */ + UserReportCreateRequest: { + /** Name */ + name?: string | null; + /** Description */ + description?: string | null; + /** Source Note */ + source_note?: string | null; + /** Provisions */ + provisions?: components['schemas']['ReportProvision'][] | null; + validation?: components['schemas']['ReportValidation'] | null; + /** Budget Window Years */ + budget_window_years?: number | null; + /** Last Run At */ + last_run_at?: string | null; + /** + * Country Id + * @description Supported PolicyEngine country ID + * @enum {string} + */ + country_id: 'us' | 'uk'; + /** + * Legacy Report Id + * @description Exact v1 database identifier (a JSON integer) + */ + legacy_report_id: number; + /** User Policy Id */ + user_policy_id?: string | null; + /** Baseline User Policy Id */ + baseline_user_policy_id?: string | null; + }; + /** + * UserReportDetailResponse + * @description Success response holding one user-report association. + */ + UserReportDetailResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['UserReportDetailResult']; + }; + /** + * UserReportDetailResult + * @description Result holding one user-report association in ``item``. + */ + UserReportDetailResult: { + item: components['schemas']['UserReportItem']; + }; + /** + * UserReportItem + * @description A report saved by a user, with the user's name, notes and saved presentation. + * + * ``legacy_report_id`` is the v1 report output it shows. ``user_policy_id`` is + * the user's saved reform the report was run from, and + * ``baseline_user_policy_id`` the saved reform it was compared with instead of + * current law; each is null when not set or once that reform is deleted. + * ``budget_window_years`` is null for a report without a budget window. + */ + UserReportItem: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * Country Id + * @description Supported PolicyEngine country ID + * @enum {string} + */ + country_id: 'us' | 'uk'; + /** + * User Id + * Format: uuid + */ + user_id: string; + /** Report Id */ + report_id: string | null; + /** Legacy Report Id */ + legacy_report_id: number | null; + /** Name */ + name: string | null; + /** Description */ + description: string | null; + /** User Policy Id */ + user_policy_id: string | null; + /** Baseline User Policy Id */ + baseline_user_policy_id: string | null; + /** Source Note */ + source_note: string | null; + /** Provisions */ + provisions: components['schemas']['ReportProvisionItem'][] | null; + validation: components['schemas']['ReportValidationItem'] | null; + /** Budget Window Years */ + budget_window_years: number | null; + /** Last Run At */ + last_run_at: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; + /** + * UserReportPageResponse + * @description Success response holding one page of user-report associations. + */ + UserReportPageResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['UserReportPageResult']; + }; + /** + * UserReportPageResult + * @description One page of user-report associations, oldest first. + * + * ``offset`` and ``limit`` echo the request; when ``has_more`` is true, ask + * for the next page with ``offset + limit``. + */ + UserReportPageResult: { + /** Items */ + items: components['schemas']['UserReportItem'][]; + /** Offset */ + offset: number; + /** Limit */ + limit: number; + /** Has More */ + has_more: boolean; + }; + /** + * UserReportPatchRequest + * @description Explicitly supplied mutable link or presentation fields. + */ + UserReportPatchRequest: { + /** Name */ + name?: string | null; + /** Description */ + description?: string | null; + /** Source Note */ + source_note?: string | null; + /** Provisions */ + provisions?: components['schemas']['ReportProvision'][] | null; + validation?: components['schemas']['ReportValidation'] | null; + /** Budget Window Years */ + budget_window_years?: number | null; + /** Last Run At */ + last_run_at?: string | null; + /** Legacy Report Id */ + legacy_report_id?: number | null; + /** User Policy Id */ + user_policy_id?: string | null; + /** Baseline User Policy Id */ + baseline_user_policy_id?: string | null; + }; + /** + * UserSimulationCreateRequest + * @description Association fields; the owner is taken from the request credential. + */ + UserSimulationCreateRequest: { + /** + * Country Id + * @description Supported PolicyEngine country ID + * @enum {string} + */ + country_id: 'us' | 'uk'; + /** + * Legacy Simulation Id + * @description Exact v1 database identifier (a JSON integer) + */ + legacy_simulation_id: number; + /** Name */ + name?: string | null; + /** Description */ + description?: string | null; + }; + /** + * UserSimulationDetailResponse + * @description Success response holding one user-simulation association. + */ + UserSimulationDetailResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['UserSimulationDetailResult']; + }; + /** + * UserSimulationDetailResult + * @description Result holding one user-simulation association in ``item``. + */ + UserSimulationDetailResult: { + item: components['schemas']['UserSimulationItem']; + }; + /** + * UserSimulationItem + * @description A simulation saved by a user, with the user's name and description for it. + * + * ``legacy_simulation_id`` is the v1 simulation it saves. Associations can be + * renamed, pointed at another v1 simulation of the same country, or deleted. + */ + UserSimulationItem: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * Country Id + * @description Supported PolicyEngine country ID + * @enum {string} + */ + country_id: 'us' | 'uk'; + /** + * User Id + * Format: uuid + */ + user_id: string; + /** Simulation Id */ + simulation_id: string | null; + /** Legacy Simulation Id */ + legacy_simulation_id: number | null; + /** Name */ + name: string | null; + /** Description */ + description: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; + /** + * UserSimulationPageResponse + * @description Success response holding one page of user-simulation associations. + */ + UserSimulationPageResponse: { + /** + * Status + * @default ok + * @constant + */ + status: 'ok'; + /** Message */ + message?: null; + result: components['schemas']['UserSimulationPageResult']; + }; + /** + * UserSimulationPageResult + * @description One page of user-simulation associations, oldest first. + * + * ``offset`` and ``limit`` echo the request; when ``has_more`` is true, ask + * for the next page with ``offset + limit``. + */ + UserSimulationPageResult: { + /** Items */ + items: components['schemas']['UserSimulationItem'][]; + /** Offset */ + offset: number; + /** Limit */ + limit: number; + /** Has More */ + has_more: boolean; + }; + /** + * UserSimulationPatchRequest + * @description Explicitly supplied mutable link or presentation fields. + */ + UserSimulationPatchRequest: { + /** Legacy Simulation Id */ + legacy_simulation_id?: number | null; + /** Name */ + name?: string | null; + /** Description */ + description?: string | null; + }; + /** + * V2ErrorResponse + * @description Strict error response shared by every API v2 resource. + */ + V2ErrorResponse: { + /** + * Status + * @default error + * @constant + */ + status: 'error'; + /** Message */ + message: string; + }; + /** + * MetadataParameterSearchFileContent + * @description One catalog version's parameter search file, ordered by path. + * + * It holds what v3's parameter search reads, without the catalog's values. + * A reader applies v1's label fallback where a label is null. + */ + MetadataParameterSearchFileContent: { + /** Format */ + format: number; + /** Country Id */ + country_id: string; + /** Policyengine Version */ + policyengine_version: string; + /** Parameters */ + parameters: components['schemas']['MetadataParameterSearchFileParameter'][]; + /** Other Parameters */ + other_parameters: components['schemas']['MetadataParameterSearchFileLabel'][]; + /** Nodes */ + nodes: components['schemas']['MetadataParameterSearchFileNode'][]; + }; + /** + * MetadataParameterSearchFileEditabilityChange + * @description From ``start_date`` (an ISO date), whether a draft can edit the value. + */ + MetadataParameterSearchFileEditabilityChange: { + /** + * Start Date + * Format: date + */ + start_date: string; + /** Editable */ + editable: boolean; + }; + /** + * MetadataParameterSearchFileLabel + * @description Another parameter under ``gov``: its path and stored label, or null. + */ + MetadataParameterSearchFileLabel: { + /** Path */ + path: string; + /** Label */ + label: string | null; + }; + /** + * MetadataParameterSearchFileNode + * @description A node under ``gov`` that has a stored label. + */ + MetadataParameterSearchFileNode: { + /** Path */ + path: string; + /** Label */ + label: string; + }; + /** + * MetadataParameterSearchFileParameter + * @description A parameter a reform can change, with what v3's search indexes. + * + * ``label`` is the stored label, or null when none is stored. ``editable`` + * says whether a draft can edit the earliest current-law value (a number, a + * yes/no value or an infinite threshold); ``editable_changes``, when + * present, lists the dates from which that changes. + */ + MetadataParameterSearchFileParameter: { + /** Path */ + path: string; + /** Label */ + label: string | null; + /** Unit */ + unit: string | null; + /** Description */ + description: string | null; + /** Editable */ + editable: boolean; + /** Editable Changes */ + editable_changes?: + | components['schemas']['MetadataParameterSearchFileEditabilityChange'][] + | null; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + get_me_v2_me_get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MeDetailResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + delete_me_v2_me_delete: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Account data was deleted but the Auth0 user could not be removed; retrying is safe. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Auth0 Management credentials could not be obtained, or persistence is unavailable; nothing was deleted. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + patch_me_v2_me_patch: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['AccountPatchRequest']; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MeDetailResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + export_me_v2_me_export_get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MeExportResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + upgrade_me_v2_me_upgrade_post: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header: { + /** @description The browser's unauthenticated user ID (a canonical UUID). */ + 'PolicyEngine-Unauthenticated-User': string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MeUpgradeResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_user_reports_v2_user_reports_get: { + parameters: { + query: { + /** @description Zero-based result offset */ + offset?: number; + /** @description Maximum number of returned resources */ + limit?: number; + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + legacy_report_id?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserReportPageResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + create_user_report_v2_user_reports_post: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UserReportCreateRequest']; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserReportDetailResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_user_report_v2_user_reports__association_id__get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path: { + association_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserReportDetailResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + delete_user_report_route_v2_user_reports__association_id__delete: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path: { + association_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + patch_user_report_route_v2_user_reports__association_id__patch: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path: { + association_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UserReportPatchRequest']; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserReportDetailResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_shared_report_v2_shared_reports__association_id__get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path: { + association_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['SharedUserReportDetailResponse']; + }; + }; + /** @description No saved report has this ID in this country. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The ID or the query does not match the schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_user_simulations_v2_user_simulations_get: { + parameters: { + query: { + /** @description Zero-based result offset */ + offset?: number; + /** @description Maximum number of returned resources */ + limit?: number; + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + legacy_simulation_id?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserSimulationPageResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + create_user_simulation_v2_user_simulations_post: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UserSimulationCreateRequest']; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserSimulationDetailResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_user_simulation_v2_user_simulations__association_id__get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path: { + association_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserSimulationDetailResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + delete_user_simulation_route_v2_user_simulations__association_id__delete: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path: { + association_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + patch_user_simulation_route_v2_user_simulations__association_id__patch: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path: { + association_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UserSimulationPatchRequest']; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserSimulationDetailResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_user_households_v2_user_households_get: { + parameters: { + query: { + /** @description Zero-based result offset */ + offset?: number; + /** @description Maximum number of returned resources */ + limit?: number; + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + household_id?: string | null; + legacy_household_id?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserHouseholdPageResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + create_user_household_v2_user_households_post: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UserHouseholdCreateRequest']; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserHouseholdDetailResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_user_household_v2_user_households__association_id__get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path: { + association_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserHouseholdDetailResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + delete_user_household_route_v2_user_households__association_id__delete: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path: { + association_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + patch_user_household_route_v2_user_households__association_id__patch: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path: { + association_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UserHouseholdPatchRequest']; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserHouseholdDetailResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_user_policies_v2_user_policies_get: { + parameters: { + query: { + /** @description Zero-based result offset */ + offset?: number; + /** @description Maximum number of returned resources */ + limit?: number; + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policy_id?: string | null; + legacy_policy_id?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserPolicyPageResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + create_user_policy_v2_user_policies_post: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UserPolicyCreateRequest']; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserPolicyDetailResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_user_policy_v2_user_policies__association_id__get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path: { + association_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserPolicyDetailResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + delete_user_policy_route_v2_user_policies__association_id__delete: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path: { + association_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + patch_user_policy_route_v2_user_policies__association_id__patch: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path: { + association_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UserPolicyPatchRequest']; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserPolicyDetailResponse']; + }; + }; + /** @description The supplied references or countries contradict each other. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description A valid bearer access token, or a canonical UUID in the PolicyEngine-Unauthenticated-User header, is required; that UUID may not name a signed-in account. The response carries WWW-Authenticate: Bearer. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Machine (client-credentials) principals are not permitted, and unauthenticated users cannot edit account details. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The record does not exist or belongs to another user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The change conflicts with stored state, such as a record maintained by the v1 saved-policy mirror. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request body exceeds 256 KiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the schema, or a referenced v1 artifact or linked record does not exist. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The operation could not be completed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description User authentication or persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_households_v2_households_get: { + parameters: { + query: { + /** @description Zero-based result offset */ + offset?: number; + /** @description Maximum number of returned resources */ + limit?: number; + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + default_year?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HouseholdPageResponse']; + }; + }; + /** @description The household content or country selection is invalid. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The selected household does not exist. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Immutable household content conflicts with stored state. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The household request body exceeds 1 MiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the household schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Stored household integrity validation failed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Supabase household persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + create_household_v2_households_post: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['HouseholdCreateRequest']; + }; + }; + responses: { + /** @description Equivalent immutable content already exists. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HouseholdDetailResponse']; + }; + }; + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HouseholdDetailResponse']; + }; + }; + /** @description The household content or country selection is invalid. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The selected household does not exist. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Immutable household content conflicts with stored state. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The household request body exceeds 1 MiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the household schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Stored household integrity validation failed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Supabase household persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_household_v2_households__household_id__get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path: { + household_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HouseholdDetailResponse']; + }; + }; + /** @description The household content or country selection is invalid. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The selected household does not exist. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Immutable household content conflicts with stored state. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The household request body exceeds 1 MiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the household schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Stored household integrity validation failed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Supabase household persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_policies_v2_policies_get: { + parameters: { + query: { + /** @description Zero-based result offset */ + offset?: number; + /** @description Maximum number of returned resources */ + limit?: number; + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + tax_benefit_model_id?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PolicyPageResponse']; + }; + }; + /** @description The policy content or country selection is invalid. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The selected policy or catalog does not exist. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Immutable policy identity conflicts with stored state. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The policy request body exceeds 1 MiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the policy schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Stored policy integrity validation failed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Supabase policy persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + create_policy_v2_policies_post: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['PolicyCreateRequest']; + }; + }; + responses: { + /** @description Equivalent immutable content already exists. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PolicyDetailResponse']; + }; + }; + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PolicyDetailResponse']; + }; + }; + /** @description The policy content or country selection is invalid. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The selected policy or catalog does not exist. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Immutable policy identity conflicts with stored state. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The policy request body exceeds 1 MiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the policy schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Stored policy integrity validation failed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Supabase policy persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_policy_v2_policies__policy_id__get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + }; + header?: never; + path: { + policy_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PolicyDetailResponse']; + }; + }; + /** @description The policy content or country selection is invalid. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The selected policy or catalog does not exist. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Immutable policy identity conflicts with stored state. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The policy request body exceeds 1 MiB. */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request does not match the policy schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Stored policy integrity validation failed. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description Supabase policy persistence is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + list_models_v2_tax_benefit_models_get: { + parameters: { + query: { + /** @description Zero-based result offset */ + offset?: number; + /** @description Maximum number of returned resources */ + limit?: number; + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataModelPageResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_model_by_country_v2_tax_benefit_models_by_country__country_id__get: { + parameters: { + query?: { + policyengine_version?: string | null; + }; + header?: never; + path: { + country_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataModelSelectionResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_model_v2_tax_benefit_models__model_id__get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + }; + header?: never; + path: { + model_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataModelDetailResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + list_model_versions_v2_tax_benefit_model_versions_get: { + parameters: { + query: { + /** @description Zero-based result offset */ + offset?: number; + /** @description Maximum number of returned resources */ + limit?: number; + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataModelVersionPageResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_model_version_v2_tax_benefit_model_versions__version_id__get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + }; + header?: never; + path: { + version_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataModelVersionDetailResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + list_variables_v2_variables_get: { + parameters: { + query: { + /** @description Zero-based result offset */ + offset?: number; + /** @description Maximum number of returned resources */ + limit?: number; + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + /** @description Case-insensitive substring of the name, label or description */ + search?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataVariablePageResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_variable_v2_variables__variable_id__get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + }; + header?: never; + path: { + variable_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataVariableDetailResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + list_parameters_v2_parameters_get: { + parameters: { + query: { + /** @description Zero-based result offset */ + offset?: number; + /** @description Maximum number of returned resources */ + limit?: number; + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + /** @description Case-insensitive substring of the name, label or description */ + search?: string | null; + /** @description Keep only these exact parameter paths; repeat the key */ + name?: string[] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataParameterPageResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + list_parameter_children_v2_parameters_children_get: { + parameters: { + query: { + /** @description Zero-based result offset */ + offset?: number; + /** @description Maximum number of returned resources */ + limit?: number; + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + /** @description Path of the node whose direct children are listed; empty for the top level. A bracket schedule's brackets are its children. */ + parent_path?: string; + /** @description List only the children a reform can change, sorted by label, with child counts that count only those */ + reform_changeable?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataParameterChildPageResponse']; + }; + }; + /** @description The resource request or PolicyEngine.py version is invalid. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request parameters do not match the resource schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_parameter_v2_parameters__parameter_id__get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + }; + header?: never; + path: { + parameter_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataParameterDetailResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + list_parameter_values_v2_parameter_values_get: { + parameters: { + query: { + /** @description Zero-based result offset */ + offset?: number; + /** @description Maximum number of returned resources */ + limit?: number; + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + parameter_id?: string | null; + /** @description Keep only these exact parameter paths' values; repeat the key */ + parameter_name?: string[] | null; + /** @description Keep only the values in effect today (UTC) */ + current?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataParameterValuePageResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_parameter_value_v2_parameter_values__value_id__get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + }; + header?: never; + path: { + value_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataParameterValueDetailResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + list_datasets_v2_datasets_get: { + parameters: { + query: { + /** @description Zero-based result offset */ + offset?: number; + /** @description Maximum number of returned resources */ + limit?: number; + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataDatasetPageResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_dataset_v2_datasets__dataset_id__get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + }; + header?: never; + path: { + dataset_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataDatasetDetailResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + list_regions_v2_regions_get: { + parameters: { + query: { + /** @description Zero-based result offset */ + offset?: number; + /** @description Maximum number of returned resources */ + limit?: number; + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + /** @description Keep only regions of this type */ + region_type?: components['schemas']['MetadataRegionType'] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataRegionPageResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_region_by_code_v2_regions_by_code__region_code__get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + }; + header?: never; + path: { + region_code: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataRegionDetailResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_region_v2_regions__region_id__get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + }; + header?: never; + path: { + region_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataRegionDetailResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_economy_options_v2_economy_options_get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataEconomyOptionsResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_model_catalog_v2_model_catalog_get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataModelCatalogResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_catalog_variables_v2_model_catalog_variables_get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataCatalogVariablesResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_catalog_parameters_v2_model_catalog_parameters_get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + /** @description Exact parameter paths; repeat the key for each one */ + name: string[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataCatalogParametersResponse']; + }; + }; + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { + headers: { + /** @description Strong validator: a hash of the exact response body. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Includes Origin, so caches keep responses apart by origin. */ + Vary?: string; + [name: string]: unknown; + }; + content?: never; + }; + /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; + get_parameter_search_file_v2_parameters_search_file_get: { + parameters: { + query: { + /** @description Supported PolicyEngine country ID */ + country_id: 'us' | 'uk'; + policyengine_version?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The catalog version's parameter search file, gzip-encoded when the request accepts gzip. */ + 200: { + headers: { + /** @description Strong tag of this representation: the SHA-256 of the uncompressed JSON, with "-gzip" appended for the gzip-encoded one. */ + ETag?: string; + /** @description public, max-age=86400 for a named policyengine_version; public, max-age=300 for the running version. */ + 'Cache-Control'?: string; + /** @description gzip when the request accepts gzip; absent otherwise. */ + 'Content-Encoding'?: string; + /** @description Accept-Encoding. */ + Vary?: string; + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MetadataParameterSearchFileContent']; + }; + }; + /** @description If-None-Match names this file's content; the response has no body. */ + 304: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The resource request or PolicyEngine.py version is invalid. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The requested catalog resource is absent. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The dormant v2 metadata resources support GET only. */ + 405: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The request parameters do not match the resource schema. */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The resource query failed internally. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + /** @description The initialized v2 catalog is unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ErrorResponse']; + }; + }; + }; + }; +} diff --git a/app/src/api/v2UserClient.ts b/app/src/api/v2UserClient.ts index 11cbdddac..3f8645d30 100644 --- a/app/src/api/v2UserClient.ts +++ b/app/src/api/v2UserClient.ts @@ -114,8 +114,15 @@ async function readEnvelope(response: Response): Promise | n } } +/** The message for a failed response: the API's own, else one for its status. */ +export function failureMessage(status: number, apiMessage?: string | null): string { + return apiMessage || STATUS_MESSAGES[status] || `Request failed (${status})`; +} + /** The header naming who a request is made as. */ -async function credentialHeaders(credentials: V2Credentials): Promise> { +export async function credentialHeaders( + credentials: V2Credentials +): Promise> { if (credentials.kind === 'none') { return {}; } @@ -175,12 +182,7 @@ export function createV2UserClient({ const envelope = await readEnvelope(response); if (!response.ok) { - throw new V2ApiError( - response.status, - envelope?.message || - STATUS_MESSAGES[response.status] || - `Request failed (${response.status})` - ); + throw new V2ApiError(response.status, failureMessage(response.status, envelope?.message)); } if (!envelope || envelope.status !== 'ok') { throw new V2ApiError( diff --git a/app/src/tests/fixtures/scripts/publicApiClientMocks.ts b/app/src/tests/fixtures/scripts/publicApiClientMocks.ts new file mode 100644 index 000000000..4fb6d4c2f --- /dev/null +++ b/app/src/tests/fixtures/scripts/publicApiClientMocks.ts @@ -0,0 +1,46 @@ +import type { OpenAPI3 } from 'openapi-typescript'; + +/** A one-route document in the shape the Public API publishes. */ +export const ECONOMY_OPTIONS_DOCUMENT: OpenAPI3 = { + openapi: '3.1.0', + info: { title: 'PolicyEngine API', version: '0.0.0' }, + paths: { + '/v2/economy-options': { + get: { + operationId: 'get_economy_options', + parameters: [ + { name: 'country_id', in: 'query', required: true, schema: { type: 'string' } }, + ], + responses: { + '200': { + description: 'Successful Response', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/EconomyOptionsResponse' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + EconomyOptionsResponse: { + type: 'object', + required: ['status', 'result'], + properties: { + status: { type: 'string', const: 'ok' }, + result: { + type: 'object', + required: ['current_law_id'], + properties: { current_law_id: { type: 'integer' } }, + }, + }, + }, + }, + }, +}; + +/** Generating types for the whole document takes a few seconds on a slow runner. */ +export const GENERATION_TIMEOUT_MS = 30000; diff --git a/app/src/tests/unit/api/publicApi/client.test.ts b/app/src/tests/unit/api/publicApi/client.test.ts new file mode 100644 index 000000000..88fba9d10 --- /dev/null +++ b/app/src/tests/unit/api/publicApi/client.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test, vi } from 'vitest'; +import { + createPublicApiClient, + requireResult, + type CreatePublicApiClientOptions, +} from '@/api/publicApi/client'; +import { isV2ApiError, UNAUTHENTICATED_USER_HEADER } from '@/api/v2UserClient'; +import { + BROWSER_USER_ID, + errorResponse, + okResponse, + TEST_ACCESS_TOKEN, + TEST_API_BASE, +} from '@/tests/fixtures/api/v2UserMocks'; + +const ECONOMY_OPTIONS_RESULT = { + policyengine_version: '6.7.5', + current_law_id: 2, + region: [{ name: 'us', label: 'United States', type: 'national' }], + time_period: [{ name: 2026, label: '2026' }], + datasets: [], +}; + +function clientWith(credentials: CreatePublicApiClientOptions['credentials'], response: Response) { + const fetchMock = vi.fn().mockResolvedValue(response); + const client = createPublicApiClient({ + credentials, + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + return { client, fetchMock }; +} + +function sentRequest(fetchMock: ReturnType): Request { + return fetchMock.mock.calls[0][0] as Request; +} + +describe('createPublicApiClient', () => { + test('given a route and query then the request goes to the API origin', async () => { + // Given + const { client, fetchMock } = clientWith({ kind: 'none' }, okResponse(ECONOMY_OPTIONS_RESULT)); + + // When + await client.GET('/v2/economy-options', { params: { query: { country_id: 'us' } } }); + + // Then + const request = sentRequest(fetchMock); + expect(request.method).toBe('GET'); + expect(request.url).toBe(`${TEST_API_BASE}/v2/economy-options?country_id=us`); + expect(request.headers.get('Authorization')).toBeNull(); + expect(request.headers.get(UNAUTHENTICATED_USER_HEADER)).toBeNull(); + }); + + test('given a signed-in session then requests carry its access token', async () => { + // Given + const { client, fetchMock } = clientWith( + { kind: 'session', getAccessToken: async () => TEST_ACCESS_TOKEN }, + okResponse({ items: [], offset: 0, limit: 500, has_more: false }) + ); + + // When + await client.GET('/v2/user-policies', { params: { query: { country_id: 'us' } } }); + + // Then + expect(sentRequest(fetchMock).headers.get('Authorization')).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + }); + + test("given a signed-out browser then requests name the browser's user", async () => { + // Given + const { client, fetchMock } = clientWith( + { kind: 'unauthenticated', userId: BROWSER_USER_ID }, + okResponse({ items: [], offset: 0, limit: 500, has_more: false }) + ); + + // When + await client.GET('/v2/user-policies', { params: { query: { country_id: 'us' } } }); + + // Then + expect(sentRequest(fetchMock).headers.get(UNAUTHENTICATED_USER_HEADER)).toBe(BROWSER_USER_ID); + }); +}); + +describe('requireResult', () => { + test('given an ok envelope then its result is returned', async () => { + // Given + const { client } = clientWith({ kind: 'none' }, okResponse(ECONOMY_OPTIONS_RESULT)); + + // When + const result = await requireResult( + client.GET('/v2/economy-options', { params: { query: { country_id: 'us' } } }) + ); + + // Then + expect(result.current_law_id).toBe(2); + }); + + test("given an error response then it throws the API's status and message", async () => { + // Given + const { client } = clientWith( + { kind: 'none' }, + errorResponse(404, 'policyengine_version 9.9.9 is not published for us') + ); + + // When + const call = requireResult( + client.GET('/v2/economy-options', { + params: { query: { country_id: 'us', policyengine_version: '9.9.9' } }, + }) + ); + + // Then + await expect(call).rejects.toSatisfy( + (error: unknown) => + isV2ApiError(error, 404) && + error.message === 'policyengine_version 9.9.9 is not published for us' + ); + }); + + test('given a network failure then it throws status 0', async () => { + // Given + const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); + const client = createPublicApiClient({ + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + // When + const call = requireResult( + client.GET('/v2/economy-options', { params: { query: { country_id: 'us' } } }) + ); + + // Then + await expect(call).rejects.toSatisfy((error: unknown) => isV2ApiError(error, 0)); + }); +}); diff --git a/app/src/tests/unit/scripts/public-api-client.test.ts b/app/src/tests/unit/scripts/public-api-client.test.ts new file mode 100644 index 000000000..35a622c22 --- /dev/null +++ b/app/src/tests/unit/scripts/public-api-client.test.ts @@ -0,0 +1,96 @@ +// @vitest-environment node +import { readFile } from 'node:fs/promises'; +import { describe, expect, it } from 'vitest'; +import { + ECONOMY_OPTIONS_DOCUMENT, + GENERATION_TIMEOUT_MS, +} from '@/tests/fixtures/scripts/publicApiClientMocks'; +import { + compareGeneratedTypes, + GENERATED_SCHEMA_PATH, + generatePublicApiTypes, + OPENAPI_DOCUMENT_PATH, + openapiDocumentLocation, + serializeOpenapiDocument, +} from '../../../../scripts/public-api-client'; + +describe('public-api-client', () => { + it( + 'given an OpenAPI document then the types name its routes and schemas', + async () => { + // When + const generated = await generatePublicApiTypes(ECONOMY_OPTIONS_DOCUMENT); + + // Then + expect(generated).toContain('Do not edit'); + expect(generated).toContain("'/v2/economy-options'"); + expect(generated).toContain('EconomyOptionsResponse'); + expect(generated).toContain('current_law_id: number'); + }, + GENERATION_TIMEOUT_MS + ); + + it( + 'given types generated from the same document then they are up to date', + async () => { + // Given + const generated = await generatePublicApiTypes(ECONOMY_OPTIONS_DOCUMENT); + + // When + const comparison = await compareGeneratedTypes(ECONOMY_OPTIONS_DOCUMENT, generated); + + // Then + expect(comparison.upToDate).toBe(true); + }, + GENERATION_TIMEOUT_MS + ); + + it( + 'given types from an older document then they are out of date', + async () => { + // Given: the types before the API added a field + const olderTypes = await generatePublicApiTypes(ECONOMY_OPTIONS_DOCUMENT); + const newerDocument = structuredClone(ECONOMY_OPTIONS_DOCUMENT); + const result = (newerDocument.components!.schemas!.EconomyOptionsResponse as any).properties + .result; + result.properties.policyengine_version = { type: 'string' }; + + // When + const comparison = await compareGeneratedTypes(newerDocument, olderTypes); + + // Then + expect(comparison.upToDate).toBe(false); + expect(comparison.expected).toContain('policyengine_version'); + }, + GENERATION_TIMEOUT_MS + ); + + it('given an API origin then the document is read from its v2 route', () => { + expect(openapiDocumentLocation('https://api.policyengine.org')).toBe( + 'https://api.policyengine.org/v2/openapi.json' + ); + expect(openapiDocumentLocation('https://api.test.org/v2/openapi.json')).toBe( + 'https://api.test.org/v2/openapi.json' + ); + expect(openapiDocumentLocation('/tmp/openapi.json')).toBe('/tmp/openapi.json'); + }); + + it( + 'given the checked-in document then the checked-in types match it', + async () => { + // Given + const checkedInDocument = JSON.parse(await readFile(OPENAPI_DOCUMENT_PATH, 'utf8')); + const checkedInTypes = await readFile(GENERATED_SCHEMA_PATH, 'utf8'); + + // When + const comparison = await compareGeneratedTypes(checkedInDocument, checkedInTypes); + + // Then + expect(serializeOpenapiDocument(checkedInDocument)).toBe( + await readFile(OPENAPI_DOCUMENT_PATH, 'utf8') + ); + expect(comparison.upToDate).toBe(true); + }, + GENERATION_TIMEOUT_MS + ); +}); diff --git a/bun.lock b/bun.lock index 0afabc78e..6709326f0 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "policyengine-monorepo", @@ -33,6 +34,7 @@ "fuse.js": "^7.0.0", "html-to-image": "^1.11.13", "lucide-react": "^0.575.0", + "openapi-fetch": "0.17.0", "policyengine-household-wizard": "^0.1.0", "radix-ui": "^1.4.3", "react": "^19.1.0", @@ -72,6 +74,7 @@ "fast-check": "^4.10.2", "identity-obj-proxy": "^3.0.0", "jsdom": "^26.1.0", + "openapi-typescript": "7.13.0", "postcss": "^8.5.5", "postcss-simple-vars": "^7.0.1", "prettier": "^3.5.3", @@ -111,6 +114,7 @@ "html-to-image": "^1.11.13", "lucide-react": "^0.575.0", "next": "^15.3.3", + "openapi-fetch": "0.17.0", "radix-ui": "^1.4.3", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -266,10 +270,6 @@ "@dual-bundle/import-meta-resolve": ["@dual-bundle/import-meta-resolve@4.2.1", "", {}, "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg=="], - "@edge-runtime/primitives": ["@edge-runtime/primitives@4.1.0", "", {}, "sha512-Vw0lbJ2lvRUqc7/soqygUX216Xb8T3WBZ987oywz6aJqRxcwSVWwr9e+Nqo2m9bxobA9mdbWNNoRY6S9eko1EQ=="], - - "@edge-runtime/vm": ["@edge-runtime/vm@3.2.0", "", { "dependencies": { "@edge-runtime/primitives": "4.1.0" } }, "sha512-0dEVyRLM/lG4gp1R/Ik5bfPl/1wX00xFwd5KcNH602tzBa09oF7pbTKETEhR1GjZ75K6OJnYFu8II2dyMhONMw=="], - "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -630,6 +630,12 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js-replace": "^1.0.1" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="], + + "@redocly/config": ["@redocly/config@0.22.0", "", {}, "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ=="], + + "@redocly/openapi-core": ["@redocly/openapi-core@1.34.22", "", { "dependencies": { "@redocly/ajv": "8.11.2", "@redocly/config": "0.22.0", "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", "js-yaml": "4.3.2", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" } }, "sha512-dEyx4uHh6Pc6ZkdU9Xe8rA4h12Odq6QKU/fqseBn07xwVwskhHiF3KHhqmw5BR+jAiFTvKTmwCafFql/k1pSzA=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], @@ -994,6 +1000,8 @@ "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -1100,6 +1108,8 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="], + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], @@ -1142,6 +1152,8 @@ "colord": ["colord@2.9.3", "", {}, "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="], + "colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], @@ -1622,6 +1634,8 @@ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "index-to-position": ["index-to-position@1.2.0", "", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], @@ -1736,6 +1750,8 @@ "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], + "js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -2010,6 +2026,12 @@ "open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + "openapi-fetch": ["openapi-fetch@0.17.0", "", { "dependencies": { "openapi-typescript-helpers": "^0.1.0" } }, "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig=="], + + "openapi-typescript": ["openapi-typescript@7.13.0", "", { "dependencies": { "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", "parse-json": "^8.3.0", "supports-color": "^10.2.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ=="], + + "openapi-typescript-helpers": ["openapi-typescript-helpers@0.1.0", "", {}, "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw=="], + "openid-client": ["openid-client@6.8.8", "", { "dependencies": { "jose": "^6.2.12", "oauth4webapi": "^3.8.8" } }, "sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -2028,7 +2050,7 @@ "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], - "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], "parse-rect": ["parse-rect@1.2.0", "", { "dependencies": { "pick-by-alias": "^1.2.0" } }, "sha512-4QZ6KYbnE6RTwg9E0HpLchUM9EZt6DnDxajFZZDSV4p/12ZJEvPO702DZpGvRYEPo00yKDys7jASi+/w7aO8LA=="], @@ -2064,6 +2086,8 @@ "plotly.js": ["plotly.js@3.4.0", "", { "dependencies": { "@plotly/d3": "3.8.2", "@plotly/d3-sankey": "0.7.2", "@plotly/d3-sankey-circular": "0.33.1", "@plotly/mapbox-gl": "1.13.4", "@plotly/regl": "^2.1.2", "@turf/area": "^7.1.0", "@turf/bbox": "^7.1.0", "@turf/centroid": "^7.1.0", "base64-arraybuffer": "^1.0.2", "canvas-fit": "^1.5.0", "color-alpha": "1.0.4", "color-normalize": "1.5.0", "color-parse": "2.0.0", "color-rgba": "3.0.0", "country-regex": "^1.1.0", "d3-force": "^1.2.1", "d3-format": "^1.4.5", "d3-geo": "^1.12.1", "d3-geo-projection": "^2.9.0", "d3-hierarchy": "^1.1.9", "d3-interpolate": "^3.0.1", "d3-time": "^1.1.0", "d3-time-format": "^2.2.3", "fast-isnumeric": "^1.1.4", "gl-mat4": "^1.2.0", "gl-text": "^1.4.0", "has-hover": "^1.0.1", "has-passive-events": "^1.0.0", "is-mobile": "^4.0.0", "maplibre-gl": "^4.7.1", "mouse-change": "^1.4.0", "mouse-event-offset": "^3.0.2", "mouse-wheel": "^1.2.0", "native-promise-only": "^0.8.1", "parse-svg-path": "^0.1.2", "point-in-polygon": "^1.1.0", "polybooljs": "^1.2.2", "probe-image-size": "^7.2.3", "regl-error2d": "^2.0.12", "regl-line2d": "^3.1.3", "regl-scatter2d": "^3.3.1", "regl-splom": "^1.0.14", "strongly-connected-components": "^1.0.1", "superscript-text": "^1.0.0", "svg-path-sdf": "^1.1.3", "tinycolor2": "^1.4.2", "to-px": "1.0.1", "topojson-client": "^3.1.0", "webgl-context": "^2.2.0", "world-calendars": "^1.0.4" } }, "sha512-jdWfHLB8AxlGUmVhqJTGEKdwjCKGn9Yi8yg16067/JyqseuSado7F6IOM1XPZspdZyO/cf8IPuy7ROlVhqZZNw=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], + "point-in-polygon": ["point-in-polygon@1.1.0", "", {}, "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw=="], "policyengine-app-v2": ["policyengine-app-v2@workspace:app"], @@ -2462,6 +2486,8 @@ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], @@ -2506,6 +2532,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "uri-js-replace": ["uri-js-replace@1.0.2", "", {}, "sha512-VeBBt/nMxedczht3KDL2yBBMJqIxpCQLMZp3UJ1otcWwUHdQW4IiyHpJivExzSK+FdxTowRfGL4FmwDqH9+S8Q=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], @@ -2592,6 +2620,8 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], @@ -2638,6 +2668,12 @@ "@policyengine/ui-kit/lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="], + "@redocly/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@redocly/openapi-core/js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], + + "@redocly/openapi-core/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@storybook/addon-docs/@storybook/react-dom-shim": ["@storybook/react-dom-shim@8.6.14", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "storybook": "^8.6.14" } }, "sha512-0hixr3dOy3f3M+HBofp3jtMQMS+sqzjKNgl7Arfuj3fvjmyXOks/yGjDImySR4imPtEllvPZfhiQNlejheaInw=="], @@ -2680,6 +2716,8 @@ "color-normalize/color-rgba": ["color-rgba@2.4.0", "", { "dependencies": { "color-parse": "^1.4.2", "color-space": "^2.0.0" } }, "sha512-Nti4qbzr/z2LbUWySr7H9dk3Rl7gZt7ihHAxlgT4Ho90EXWkjtkL1avTleu9yeGuqrt/chxTB6GKK8nZZ6V0+Q=="], + "cosmiconfig/parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "d3-force/d3-timer": ["d3-timer@1.0.10", "", {}, "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw=="], "d3-geo-projection/d3-array": ["d3-array@1.2.4", "", {}, "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw=="], @@ -2770,6 +2808,8 @@ "node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "openapi-typescript/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "plotly.js/d3-geo": ["d3-geo@1.12.1", "", { "dependencies": { "d3-array": "1" } }, "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg=="], @@ -2836,6 +2876,8 @@ "@plotly/d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@storybook/core/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@storybook/core/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -2996,6 +3038,8 @@ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@redocly/openapi-core/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], diff --git a/calculator-app/package.json b/calculator-app/package.json index c924d9ff6..e05193046 100644 --- a/calculator-app/package.json +++ b/calculator-app/package.json @@ -30,6 +30,7 @@ "html-to-image": "^1.11.13", "lucide-react": "^0.575.0", "next": "^15.3.3", + "openapi-fetch": "0.17.0", "radix-ui": "^1.4.3", "react": "^19.1.0", "react-dom": "^19.1.0", From 649822a49822013df8c6ea74e00e9eee5e803ee2 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:02:46 +0400 Subject: [PATCH 02/28] Load v3's model metadata from the small /v2/model-catalog behind a switch v3 gains one place that chooses its Public API routes, the constant `V3_API_ROUTES` in `libs/flagship/v3ApiRoutes.ts`. It stays 'v1' until the switch, so nothing users reach changes: the workspace and the older report builder still load the whole model from `GET /{country}/metadata`. With the constant at 'v2', a v3 workspace page loads only `GET /v2/model-catalog` at start: the catalog version's PolicyEngine.py version, entities, basic inputs, regions, years and datasets, in the shape the metadata store already holds. Parameters and variables start empty, to be loaded on demand, and no whole parameter tree is built. The workspace counts as ready only with metadata from the routes it uses, and a retry asks those routes again. The older report builder stays on v1 either way. Co-Authored-By: Claude Opus 5.5 --- app/src/api/publicApi/modelCatalog.ts | 100 ++++++++++++++++ app/src/hooks/useFlagshipMetadataStatus.ts | 15 ++- app/src/hooks/useMetadata.ts | 16 ++- app/src/libs/flagship/v3ApiRoutes.ts | 37 ++++++ app/src/reducers/metadataReducer.ts | 57 +++++++-- .../api/publicApi/modelCatalogMocks.ts | 96 +++++++++++++++ .../unit/api/publicApi/modelCatalog.test.ts | 102 ++++++++++++++++ .../calculator/CalculatorProviders.test.tsx | 63 ++++++++++ .../useFlagshipMetadataStatusV2.test.tsx | 71 +++++++++++ app/src/tests/unit/hooks/useMetadata.test.tsx | 37 +++++- .../unit/libs/flagship/v3ApiRoutes.test.ts | 23 ++++ .../unit/reducers/metadataReducerV2.test.ts | 113 ++++++++++++++++++ app/src/types/metadata.ts | 24 ++++ .../src/app/[countryId]/providers.tsx | 7 +- 14 files changed, 739 insertions(+), 22 deletions(-) create mode 100644 app/src/api/publicApi/modelCatalog.ts create mode 100644 app/src/libs/flagship/v3ApiRoutes.ts create mode 100644 app/src/tests/fixtures/api/publicApi/modelCatalogMocks.ts create mode 100644 app/src/tests/unit/api/publicApi/modelCatalog.test.ts create mode 100644 app/src/tests/unit/calculator/CalculatorProviders.test.tsx create mode 100644 app/src/tests/unit/hooks/useFlagshipMetadataStatusV2.test.tsx create mode 100644 app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts create mode 100644 app/src/tests/unit/reducers/metadataReducerV2.test.ts diff --git a/app/src/api/publicApi/modelCatalog.ts b/app/src/api/publicApi/modelCatalog.ts new file mode 100644 index 000000000..a48a850e6 --- /dev/null +++ b/app/src/api/publicApi/modelCatalog.ts @@ -0,0 +1,100 @@ +import { createPublicApiClient, requireResult, type PublicApiClient } from '@/api/publicApi/client'; +import type { components } from '@/api/publicApi/schema'; +import type { MetadataApiPayload, MetadataRegionEntry } from '@/types/metadata'; + +/** + * The v3 workspace's model metadata at start, from the Public API's + * `GET /v2/model-catalog`: only the small parts of the current catalog + * version (its PolicyEngine.py version, entities, basic inputs, regions, + * years and datasets), in the shape `GET /{country}/metadata` returns, so + * everything that reads the metadata store keeps working. + * + * Parameters and variables are not part of it. v3 loads them on demand + * (the parameters of what is open, the parameter tree one level at a time, + * and a search file in the background), so they start empty here. v1's + * variable modules, modelled policies and SPM settings are not published + * by v2 and stay empty too. + */ + +type Schemas = components['schemas']; + +/** Region types v1 listed per country; v2 also lists places, which v3 never offered. */ +const V1_REGION_TYPES: Record = { + us: ['national', 'state', 'congressional_district'], + uk: ['national', 'country', 'constituency', 'local_authority'], +}; + +function regionEntries( + countryId: string, + catalog: Schemas['MetadataModelCatalogResult'] +): MetadataRegionEntry[] { + const offered = V1_REGION_TYPES[countryId] ?? ['national']; + const regionsByCode = new Map(catalog.regions.map((region) => [region.code, region])); + return catalog.economy_options.region + .filter((option) => offered.includes(option.type)) + .map((option) => { + const region = regionsByCode.get(option.name); + return { + name: option.name, + label: option.label, + type: option.type, + // v1 gave the state only for congressional districts. + ...(option.type === 'congressional_district' && region?.state_code + ? { state_abbreviation: region.state_code, state_name: region.state_name ?? '' } + : {}), + } as MetadataRegionEntry; + }); +} + +function entityCollection(entities: Schemas['MetadataEntity'][]): Record { + return Object.fromEntries( + entities.map((entity) => [ + entity.key, + { key: entity.key, plural: entity.plural, is_person: entity.is_person }, + ]) + ); +} + +/** A country's model metadata at start, from the v2 routes, as a v1 metadata envelope. */ +export async function fetchV2ModelCatalogMetadata( + countryId: string, + client: PublicApiClient = createPublicApiClient() +): Promise { + const catalog = await requireResult( + client.GET('/v2/model-catalog', { + params: { query: { country_id: countryId as 'us' | 'uk' } }, + }) + ); + + return { + status: 'ok', + message: null, + result: { + variables: {}, + parameters: {}, + entities: entityCollection(catalog.entities), + variableModules: {}, + economy_options: { + region: regionEntries(countryId, catalog), + time_period: catalog.economy_options.time_period.map((period) => ({ + name: period.name, + label: period.label, + })), + datasets: catalog.economy_options.datasets.map((dataset) => ({ + name: dataset.name, + label: dataset.label, + title: dataset.label, + default: dataset.default, + })), + }, + current_law_id: catalog.model_version.current_law_id, + basicInputs: catalog.basic_inputs, + modelled_policies: { core: {}, filtered: {} }, + version: catalog.policyengine_version, + v2Catalog: { + policyengineVersion: catalog.policyengine_version, + taxBenefitModelId: catalog.model.id, + }, + }, + }; +} diff --git a/app/src/hooks/useFlagshipMetadataStatus.ts b/app/src/hooks/useFlagshipMetadataStatus.ts index 4c18ee752..b1d74e95e 100644 --- a/app/src/hooks/useFlagshipMetadataStatus.ts +++ b/app/src/hooks/useFlagshipMetadataStatus.ts @@ -1,7 +1,8 @@ import { useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; -import { fetchMetadataThunk } from '@/reducers/metadataReducer'; +import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; +import { fetchMetadataThunk, metadataRequest } from '@/reducers/metadataReducer'; import type { AppDispatch, RootState } from '@/store'; export type FlagshipMetadataStatus = 'loading' | 'error' | 'ready'; @@ -22,12 +23,20 @@ export function useFlagshipMetadataStatus(): { const dispatch = useDispatch(); const loading = useSelector((state: RootState) => state.metadata.loading); const error = useSelector((state: RootState) => state.metadata.error); + // Ready only with metadata from the routes v3 uses (see v3ApiRoutes): on + // the v2 routes that is the small model catalog, and v3 loads parameters + // on demand behind it. const ready = useSelector( (state: RootState) => - state.metadata.version !== null && state.metadata.currentCountry === countryId + state.metadata.version !== null && + state.metadata.currentCountry === countryId && + (state.metadata.routes ?? 'v1') === V3_API_ROUTES ); - const retry = useCallback(() => dispatch(fetchMetadataThunk(countryId)), [dispatch, countryId]); + const retry = useCallback( + () => dispatch(fetchMetadataThunk(metadataRequest(countryId, V3_API_ROUTES))), + [dispatch, countryId] + ); const status: FlagshipMetadataStatus = !loading && error ? 'error' : ready && !loading ? 'ready' : 'loading'; diff --git a/app/src/hooks/useMetadata.ts b/app/src/hooks/useMetadata.ts index e6724d212..b58b4c7f5 100644 --- a/app/src/hooks/useMetadata.ts +++ b/app/src/hooks/useMetadata.ts @@ -1,7 +1,8 @@ import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { fetchMetadataThunk } from '@/reducers/metadataReducer'; +import { fetchMetadataThunk, metadataRequest } from '@/reducers/metadataReducer'; import { AppDispatch, RootState } from '@/store'; +import type { MetadataRoutes } from '@/types/metadata'; /** * Hook that ensures metadata is fetched for the current country. @@ -10,17 +11,20 @@ import { AppDispatch, RootState } from '@/store'; * - Component mounts and no metadata exists * - countryId parameter changes (e.g., when URL route changes) * - Current country in state differs from requested country + * - The metadata in state came from other routes than `routes` (v1's + * whole-model route, or v2's small model catalog; see v3ApiRoutes) * * Components should use useSelector to read metadata from Redux state */ -export function useFetchMetadata(countryId: string): void { +export function useFetchMetadata(countryId: string, routes: MetadataRoutes = 'v1'): void { const dispatch = useDispatch(); const metadata = useSelector((state: RootState) => state.metadata); + const loadedRoutes = metadata.routes ?? 'v1'; useEffect(() => { - // Only fetch if we don't already have metadata for this country - if (!metadata.version || countryId !== metadata.currentCountry) { - dispatch(fetchMetadataThunk(countryId)); + // Only fetch if we don't already have metadata for this country from these routes + if (!metadata.version || countryId !== metadata.currentCountry || loadedRoutes !== routes) { + dispatch(fetchMetadataThunk(metadataRequest(countryId, routes))); } - }, [countryId, metadata.currentCountry, metadata.version, dispatch]); + }, [countryId, routes, loadedRoutes, metadata.currentCountry, metadata.version, dispatch]); } diff --git a/app/src/libs/flagship/v3ApiRoutes.ts b/app/src/libs/flagship/v3ApiRoutes.ts new file mode 100644 index 000000000..5d5bced4a --- /dev/null +++ b/app/src/libs/flagship/v3ApiRoutes.ts @@ -0,0 +1,37 @@ +import type { MetadataRoutes } from '@/types/metadata'; + +/** + * Which Public API routes the v3 workspace uses. + * + * - 'v1': `GET /{country}/metadata` for the whole model at start, and saved + * reforms as `/v2/user-policies` rows pointing at v1 policies + * (`POST /{country}/policy`, `legacy_policy_id`). + * - 'v2': only the small `/v2/model-catalog` at start; parameters, the + * parameter tree and search load on demand from the v2 catalog routes; + * saved reforms are `/v2/user-policies` rows pointing at `/v2/policies` + * (`policy_id`). + * + * This constant is the only place v3 chooses. It is not a runtime flag or an + * environment variable, so every build behaves the same. It stays 'v1' until + * the switch pull request (plan item 4.01), which changes this line to 'v2' + * (and the test that pins it). The older report builder, the `(calculator)` + * route group, stays on v1 regardless. + */ +export type V3ApiRoutes = MetadataRoutes; + +export const V3_API_ROUTES: V3ApiRoutes = 'v1'; + +/** The Next.js route group holding the v3 workspace pages. */ +export const V3_WORKSPACE_ROUTE_GROUP = '(workspace)'; + +/** + * Where model metadata loads from for a page in this route group (the + * segment below `[countryId]`): v3 follows `v3Routes` (V3_API_ROUTES unless + * a test names the routes after the switch); everything else stays on v1. + */ +export function metadataRoutesForRouteGroup( + routeGroup: string | null, + v3Routes: V3ApiRoutes = V3_API_ROUTES +): V3ApiRoutes { + return routeGroup === V3_WORKSPACE_ROUTE_GROUP ? v3Routes : 'v1'; +} diff --git a/app/src/reducers/metadataReducer.ts b/app/src/reducers/metadataReducer.ts index e1daefd24..589225c2d 100644 --- a/app/src/reducers/metadataReducer.ts +++ b/app/src/reducers/metadataReducer.ts @@ -1,8 +1,28 @@ import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; -// Import the API function +// Import the API functions import { fetchMetadata as fetchMetadataApi } from '@/api/metadata'; +import { fetchV2ModelCatalogMetadata } from '@/api/publicApi/modelCatalog'; import { buildParameterTree } from '@/libs/buildParameterTree'; -import { MetadataState } from '@/types/metadata'; +import { MetadataRoutes, MetadataState } from '@/types/metadata'; + +/** + * What to load: a country alone reads `GET /{country}/metadata` (v1), as + * every caller always has; `{countryId, routes: 'v2'}` reads the small + * `GET /v2/model-catalog`, and v3 loads the rest on demand. + */ +export type MetadataRequest = string | { countryId: string; routes: MetadataRoutes }; + +export function metadataRequest(countryId: string, routes: MetadataRoutes): MetadataRequest { + return routes === 'v1' ? countryId : { countryId, routes }; +} + +function requestCountry(request: MetadataRequest): string { + return typeof request === 'string' ? request : request.countryId; +} + +function requestRoutes(request: MetadataRequest): MetadataRoutes { + return typeof request === 'string' ? 'v1' : request.routes; +} const initialState: MetadataState = { loading: false, @@ -25,13 +45,17 @@ const initialState: MetadataState = { // Async thunk for fetching metadata export const fetchMetadataThunk = createAsyncThunk< { data: Awaited>; country: string }, - string, + MetadataRequest, { state: { metadata: MetadataState } } >( 'metadata/fetch', - async (country: string, { rejectWithValue }) => { + async (request: MetadataRequest, { rejectWithValue }) => { + const country = requestCountry(request); try { - const data = await fetchMetadataApi(country); + const data = + requestRoutes(request) === 'v2' + ? await fetchV2ModelCatalogMetadata(country) + : await fetchMetadataApi(country); if (data.status !== 'ok' || !data.result?.version) { return rejectWithValue( data.message || 'Model information did not include a resolved version' @@ -44,9 +68,13 @@ export const fetchMetadataThunk = createAsyncThunk< }, { // Prevent duplicate fetches (e.g. from React StrictMode double-invoking effects) - condition: (country, { getState }) => { + condition: (request, { getState }) => { const { metadata } = getState(); - if (metadata.loading && metadata.currentCountry === country) { + if ( + metadata.loading && + metadata.currentCountry === requestCountry(request) && + (metadata.routes ?? 'v1') === requestRoutes(request) + ) { return false; } }, @@ -72,11 +100,13 @@ const metadataSlice = createSlice({ extraReducers: (builder) => { builder .addCase(fetchMetadataThunk.pending, (_state, action) => { + const routes = requestRoutes(action.meta.arg); return { ...initialState, - currentCountry: action.meta.arg, + currentCountry: requestCountry(action.meta.arg), currentRequestId: action.meta.requestId, loading: true, + ...(routes === 'v1' ? {} : { routes }), }; }) .addCase(fetchMetadataThunk.fulfilled, (state, action) => { @@ -107,6 +137,15 @@ const metadataSlice = createSlice({ state.version = body.version; state.spm = body.spm; + if (requestRoutes(action.meta.arg) === 'v2') { + // Parameters arrive on demand, so there is no whole tree to build: + // v3 reads the tree one level at a time from the v2 routes. + state.routes = 'v2'; + state.v2Catalog = body.v2Catalog ?? null; + state.parameterTree = null; + return; + } + // Build parameter tree from parameters (following V1 approach) try { state.parameterTree = buildParameterTree(body.parameters) || null; @@ -118,7 +157,7 @@ const metadataSlice = createSlice({ if ( !state.currentRequestId || state.currentRequestId !== action.meta.requestId || - state.currentCountry !== action.meta.arg + state.currentCountry !== requestCountry(action.meta.arg) ) { return; } diff --git a/app/src/tests/fixtures/api/publicApi/modelCatalogMocks.ts b/app/src/tests/fixtures/api/publicApi/modelCatalogMocks.ts new file mode 100644 index 000000000..9c8be56ef --- /dev/null +++ b/app/src/tests/fixtures/api/publicApi/modelCatalogMocks.ts @@ -0,0 +1,96 @@ +import type { components } from '@/api/publicApi/schema'; + +type Schemas = components['schemas']; + +export const TEST_POLICYENGINE_VERSION = '6.11.1'; +export const TEST_TAX_BENEFIT_MODEL_ID = '0b1f6c7e-2f43-4c1a-9f0e-1d3e5a7b9c11'; +export const TEST_MODEL_VERSION_ID = '5d2c8a90-6e1b-4f3a-8c7d-2b4e6f8a0c22'; +export const TEST_DEFAULT_DATASET_ID = '9a7b5c3d-1e2f-4a6b-8c0d-3e5f7a9b1c33'; +export const TEST_CURRENT_LAW_ID = 2; + +function region( + code: string, + label: string, + regionType: Schemas['MetadataRegion']['region_type'], + state: { code: string; name: string } | null = null +): Schemas['MetadataRegion'] { + return { + id: `region-${code}`, + code, + label, + region_type: regionType, + requires_filter: regionType !== 'national', + filter_field: null, + filter_value: null, + filter_strategy: null, + parent_code: regionType === 'national' ? null : 'us', + state_code: state?.code ?? null, + state_name: state?.name ?? null, + default_dataset_id: TEST_DEFAULT_DATASET_ID, + }; +} + +const CALIFORNIA = { code: 'CA', name: 'California' }; + +/** A small US catalog version, as `GET /v2/model-catalog` returns it. */ +export const US_MODEL_CATALOG_RESULT: Schemas['MetadataModelCatalogResult'] = { + policyengine_version: TEST_POLICYENGINE_VERSION, + catalog_format: 2, + model: { id: TEST_TAX_BENEFIT_MODEL_ID, name: 'policyengine-us', description: null }, + model_version: { + id: TEST_MODEL_VERSION_ID, + model_id: TEST_TAX_BENEFIT_MODEL_ID, + version: '2.2.1', + description: null, + current_law_id: TEST_CURRENT_LAW_ID, + metadata_time_periods: [2025, 2026], + }, + entities: [ + { key: 'person', plural: 'people', is_person: true }, + { key: 'household', plural: 'households', is_person: false }, + ], + basic_inputs: ['age', 'employment_income'], + regions: [ + region('us', 'United States', 'national'), + region('state/ca', 'California', 'state', CALIFORNIA), + region( + 'congressional_district/CA-01', + "California's 1st congressional district", + 'congressional_district', + CALIFORNIA + ), + region('place/CA-44000', 'Los Angeles city', 'place', CALIFORNIA), + ], + datasets: [ + { + id: TEST_DEFAULT_DATASET_ID, + name: 'populace_us_2024', + description: null, + year: 2024, + storage_path: null, + is_output_dataset: false, + }, + ], + economy_options: { + region: [ + { name: 'us', label: 'United States', type: 'national' }, + { name: 'state/ca', label: 'California', type: 'state' }, + { + name: 'congressional_district/CA-01', + label: "California's 1st congressional district", + type: 'congressional_district', + }, + ], + time_period: [ + { name: 2025, label: '2025' }, + { name: 2026, label: '2026' }, + ], + datasets: [{ name: 'populace_us_2024', label: 'Microcosm', default: true }], + }, +}; + +export function modelCatalogEnvelope( + result: Schemas['MetadataModelCatalogResult'] = US_MODEL_CATALOG_RESULT +) { + return { status: 'ok', message: null, result }; +} diff --git a/app/src/tests/unit/api/publicApi/modelCatalog.test.ts b/app/src/tests/unit/api/publicApi/modelCatalog.test.ts new file mode 100644 index 000000000..60f7fa4e1 --- /dev/null +++ b/app/src/tests/unit/api/publicApi/modelCatalog.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test, vi } from 'vitest'; +import { createPublicApiClient } from '@/api/publicApi/client'; +import { fetchV2ModelCatalogMetadata } from '@/api/publicApi/modelCatalog'; +import { + modelCatalogEnvelope, + TEST_CURRENT_LAW_ID, + TEST_POLICYENGINE_VERSION, + TEST_TAX_BENEFIT_MODEL_ID, +} from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; +import { errorResponse, okResponse, TEST_API_BASE } from '@/tests/fixtures/api/v2UserMocks'; + +function clientReturning(response: Response) { + const fetchMock = vi.fn().mockResolvedValue(response); + const client = createPublicApiClient({ + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + return { client, fetchMock }; +} + +describe('fetchV2ModelCatalogMetadata', () => { + test('given a country then it reads only the small model catalog for it', async () => { + // Given + const { client, fetchMock } = clientReturning(okResponse(modelCatalogEnvelope().result)); + + // When + await fetchV2ModelCatalogMetadata('us', client); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(1); + const request = fetchMock.mock.calls[0][0] as Request; + expect(request.url).toBe(`${TEST_API_BASE}/v2/model-catalog?country_id=us`); + }); + + test('given the catalog then the metadata names its version and the v2 catalog it came from', async () => { + const { client } = clientReturning(okResponse(modelCatalogEnvelope().result)); + + const payload = await fetchV2ModelCatalogMetadata('us', client); + + expect(payload.status).toBe('ok'); + expect(payload.result.version).toBe(TEST_POLICYENGINE_VERSION); + expect(payload.result.current_law_id).toBe(TEST_CURRENT_LAW_ID); + expect(payload.result.v2Catalog).toEqual({ + policyengineVersion: TEST_POLICYENGINE_VERSION, + taxBenefitModelId: TEST_TAX_BENEFIT_MODEL_ID, + }); + }); + + test('given the catalog then parameters and variables are left to load on demand', async () => { + const { client } = clientReturning(okResponse(modelCatalogEnvelope().result)); + + const payload = await fetchV2ModelCatalogMetadata('us', client); + + expect(payload.result.parameters).toEqual({}); + expect(payload.result.variables).toEqual({}); + }); + + test('given the catalog then entities and basic inputs take the shape v3 reads', async () => { + const { client } = clientReturning(okResponse(modelCatalogEnvelope().result)); + + const payload = await fetchV2ModelCatalogMetadata('us', client); + + expect(payload.result.basicInputs).toEqual(['age', 'employment_income']); + expect(payload.result.entities).toEqual({ + person: { key: 'person', plural: 'people', is_person: true }, + household: { key: 'household', plural: 'households', is_person: false }, + }); + }); + + test('given the catalog then regions, years and datasets take the shape v1 gave', async () => { + const { client } = clientReturning(okResponse(modelCatalogEnvelope().result)); + + const payload = await fetchV2ModelCatalogMetadata('us', client); + + expect(payload.result.economy_options.region).toEqual([ + { name: 'us', label: 'United States', type: 'national' }, + { name: 'state/ca', label: 'California', type: 'state' }, + { + name: 'congressional_district/CA-01', + label: "California's 1st congressional district", + type: 'congressional_district', + state_abbreviation: 'CA', + state_name: 'California', + }, + ]); + expect(payload.result.economy_options.time_period).toEqual([ + { name: 2025, label: '2025' }, + { name: 2026, label: '2026' }, + ]); + expect(payload.result.economy_options.datasets).toEqual([ + { name: 'populace_us_2024', label: 'Microcosm', title: 'Microcosm', default: true }, + ]); + }); + + test('given a failed request then it rejects with the API status', async () => { + const { client } = clientReturning(errorResponse(503, 'The catalog is not published')); + + await expect(fetchV2ModelCatalogMetadata('us', client)).rejects.toMatchObject({ + status: 503, + }); + }); +}); diff --git a/app/src/tests/unit/calculator/CalculatorProviders.test.tsx b/app/src/tests/unit/calculator/CalculatorProviders.test.tsx new file mode 100644 index 000000000..280f5bd07 --- /dev/null +++ b/app/src/tests/unit/calculator/CalculatorProviders.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { CalculatorProviders } from '../../../../../calculator-app/src/app/[countryId]/providers'; + +const selectedLayout = vi.hoisted(() => ({ routeGroup: null as string | null })); +const mockUseFetchMetadata = vi.hoisted(() => vi.fn()); + +vi.mock('next/navigation', () => ({ + useSelectedLayoutSegment: () => selectedLayout.routeGroup, +})); + +vi.mock('@/hooks/useMetadata', () => ({ + useFetchMetadata: mockUseFetchMetadata, +})); + +// Outside production the providers load the journey profiler, which needs a window. +vi.mock('@/utils/journeyProfiler', () => ({})); + +/** v3 after the switch: the workspace route group reads the v2 routes. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + V3_API_ROUTES: 'v2', + metadataRoutesForRouteGroup: (routeGroup: string | null) => + original.metadataRoutesForRouteGroup(routeGroup, 'v2'), + }; +}); + +function renderCountryPage(routeGroup: string | null) { + selectedLayout.routeGroup = routeGroup; + render( + + +

Country page

+
+
+ ); +} + +describe('CalculatorProviders metadata routes', () => { + beforeEach(() => { + mockUseFetchMetadata.mockClear(); + }); + + test('given a v3 workspace page then metadata is fetched from the v3 routes', () => { + // When + renderCountryPage('(workspace)'); + + // Then + expect(screen.getByText('Country page')).toBeInTheDocument(); + expect(mockUseFetchMetadata).toHaveBeenLastCalledWith('us', 'v2'); + }); + + test('given a legacy calculator page then metadata is fetched from v1', () => { + // When + renderCountryPage('(calculator)'); + + // Then + expect(mockUseFetchMetadata).toHaveBeenLastCalledWith('us', 'v1'); + }); +}); diff --git a/app/src/tests/unit/hooks/useFlagshipMetadataStatusV2.test.tsx b/app/src/tests/unit/hooks/useFlagshipMetadataStatusV2.test.tsx new file mode 100644 index 000000000..d3d7adbd7 --- /dev/null +++ b/app/src/tests/unit/hooks/useFlagshipMetadataStatusV2.test.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { seedMetadataError } from '@/tests/fixtures/hooks/useFlagshipMetadataStatusMocks'; + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +const mockFetchV1Metadata = vi.fn(); +const mockFetchV2ModelCatalog = vi.fn(); + +vi.mock('@/api/metadata', async (importOriginal) => ({ + ...(await importOriginal()), + fetchMetadata: (...args: unknown[]) => mockFetchV1Metadata(...args), +})); + +vi.mock('@/api/publicApi/modelCatalog', async (importOriginal) => ({ + ...(await importOriginal()), + fetchV2ModelCatalogMetadata: (...args: unknown[]) => mockFetchV2ModelCatalog(...args), +})); + +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +describe('useFlagshipMetadataStatus with v3 on the v2 routes', () => { + beforeEach(() => { + store.dispatch(clearMetadata()); + mockFetchV1Metadata.mockReset(); + mockFetchV2ModelCatalog.mockReset(); + mockFetchV2ModelCatalog.mockReturnValue(new Promise(() => {})); + }); + + test('given metadata from the v1 route then v3 is not ready yet', () => { + // Given: the legacy calculator loaded v1 metadata before the user opened v3 + seedDeductionMetadata(); + + // When + const { result } = renderHook(() => useFlagshipMetadataStatus(), { wrapper }); + + // Then + expect(result.current.status).toBe('loading'); + }); + + test('given a failed fetch then retry asks the v2 routes', () => { + // Given + seedMetadataError(); + const { result } = renderHook(() => useFlagshipMetadataStatus(), { wrapper }); + + // When + act(() => { + result.current.retry(); + }); + + // Then + expect(mockFetchV2ModelCatalog).toHaveBeenCalledWith('us'); + expect(mockFetchV1Metadata).not.toHaveBeenCalled(); + expect(result.current.status).toBe('loading'); + }); +}); diff --git a/app/src/tests/unit/hooks/useMetadata.test.tsx b/app/src/tests/unit/hooks/useMetadata.test.tsx index 58573a392..bb30f5fcf 100644 --- a/app/src/tests/unit/hooks/useMetadata.test.tsx +++ b/app/src/tests/unit/hooks/useMetadata.test.tsx @@ -18,11 +18,14 @@ import { // Mock must be before imports that use it vi.mock('@/reducers/metadataReducer', () => ({ - fetchMetadataThunk: vi.fn((country: string) => { - const thunk = () => ({ type: 'metadata/fetch/pending', meta: { arg: country } }); - thunk.country = country; // Add country as property for testing + fetchMetadataThunk: vi.fn((request: string | { countryId: string; routes: string }) => { + const thunk = () => ({ type: 'metadata/fetch/pending', meta: { arg: request } }); + // Record a v1 request as its country, as before, and a v2 one by its routes. + thunk.country = typeof request === 'string' ? request : `${request.countryId}:v2`; return thunk; }), + metadataRequest: (countryId: string, routes: string) => + routes === 'v1' ? countryId : { countryId, routes }, default: (state = {}) => state, })); @@ -203,4 +206,32 @@ describe('useFetchMetadata', () => { // Then - unmount should not throw expect(() => unmount()).not.toThrow(); }); + + describe('metadata routes', () => { + test('given v1 metadata loaded and v2 requested then the v2 routes are fetched', () => { + // Given + const { wrapper } = createTestSetup({ ...mockLoadedMetadataState, routes: 'v1' }); + + // When + renderHook(() => useFetchMetadata(mockLoadedMetadataState.currentCountry!, 'v2'), { + wrapper, + }); + + // Then + expect(dispatchedThunks).toEqual([`${mockLoadedMetadataState.currentCountry}:v2`]); + }); + + test('given v2 metadata loaded and v2 requested then nothing is fetched', () => { + // Given + const { wrapper } = createTestSetup({ ...mockLoadedMetadataState, routes: 'v2' }); + + // When + renderHook(() => useFetchMetadata(mockLoadedMetadataState.currentCountry!, 'v2'), { + wrapper, + }); + + // Then + expect(dispatchedThunks).toEqual([]); + }); + }); }); diff --git a/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts b/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts new file mode 100644 index 000000000..fbd76ed19 --- /dev/null +++ b/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'vitest'; +import { + metadataRoutesForRouteGroup, + V3_API_ROUTES, + V3_WORKSPACE_ROUTE_GROUP, +} from '@/libs/flagship/v3ApiRoutes'; + +describe('v3ApiRoutes', () => { + test('given this release then v3 still uses the v1 routes until the switch', () => { + expect(V3_API_ROUTES).toBe('v1'); + }); + + test('given the v3 workspace route group then metadata follows the routes v3 uses', () => { + expect(metadataRoutesForRouteGroup(V3_WORKSPACE_ROUTE_GROUP, 'v2')).toBe('v2'); + expect(metadataRoutesForRouteGroup(V3_WORKSPACE_ROUTE_GROUP, 'v1')).toBe('v1'); + expect(metadataRoutesForRouteGroup(V3_WORKSPACE_ROUTE_GROUP)).toBe(V3_API_ROUTES); + }); + + test('given the older report builder or no route group then metadata stays on v1', () => { + expect(metadataRoutesForRouteGroup('(calculator)', 'v2')).toBe('v1'); + expect(metadataRoutesForRouteGroup(null, 'v2')).toBe('v1'); + }); +}); diff --git a/app/src/tests/unit/reducers/metadataReducerV2.test.ts b/app/src/tests/unit/reducers/metadataReducerV2.test.ts new file mode 100644 index 000000000..521f0ad45 --- /dev/null +++ b/app/src/tests/unit/reducers/metadataReducerV2.test.ts @@ -0,0 +1,113 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import metadataReducer, { fetchMetadataThunk, metadataRequest } from '@/reducers/metadataReducer'; +import { + TEST_POLICYENGINE_VERSION, + TEST_TAX_BENEFIT_MODEL_ID, +} from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; + +const mockFetchV1Metadata = vi.fn(); +const mockFetchV2ModelCatalogMetadata = vi.fn(); + +vi.mock('@/api/metadata', () => ({ + fetchMetadata: (...args: unknown[]) => mockFetchV1Metadata(...args), +})); +vi.mock('@/api/publicApi/modelCatalog', () => ({ + fetchV2ModelCatalogMetadata: (...args: unknown[]) => mockFetchV2ModelCatalogMetadata(...args), +})); + +function v2Payload() { + return { + status: 'ok', + message: null, + result: { + variables: {}, + parameters: {}, + entities: { person: { key: 'person', plural: 'people', is_person: true } }, + variableModules: {}, + economy_options: { region: [], time_period: [], datasets: [] }, + current_law_id: 2, + basicInputs: ['age'], + modelled_policies: { core: {}, filtered: {} }, + version: TEST_POLICYENGINE_VERSION, + v2Catalog: { + policyengineVersion: TEST_POLICYENGINE_VERSION, + taxBenefitModelId: TEST_TAX_BENEFIT_MODEL_ID, + }, + }, + }; +} + +function testStore() { + return configureStore({ reducer: { metadata: metadataReducer } }); +} + +describe('metadataReducer with the v2 routes', () => { + beforeEach(() => { + mockFetchV1Metadata.mockReset(); + mockFetchV2ModelCatalogMetadata.mockReset(); + }); + + test('given a v1 request then the request is the country, as every caller passes it', () => { + expect(metadataRequest('us', 'v1')).toBe('us'); + expect(metadataRequest('us', 'v2')).toEqual({ countryId: 'us', routes: 'v2' }); + }); + + test('given a v2 request then the model catalog loads and nothing else', async () => { + // Given + mockFetchV2ModelCatalogMetadata.mockResolvedValue(v2Payload()); + const store = testStore(); + + // When + await store.dispatch(fetchMetadataThunk(metadataRequest('us', 'v2'))); + + // Then + expect(mockFetchV2ModelCatalogMetadata).toHaveBeenCalledWith('us'); + expect(mockFetchV1Metadata).not.toHaveBeenCalled(); + const state = store.getState().metadata; + expect(state.routes).toBe('v2'); + expect(state.version).toBe(TEST_POLICYENGINE_VERSION); + expect(state.basicInputs).toEqual(['age']); + expect(state.parameters).toEqual({}); + expect(state.parameterTree).toBeNull(); + expect(state.v2Catalog).toEqual({ + policyengineVersion: TEST_POLICYENGINE_VERSION, + taxBenefitModelId: TEST_TAX_BENEFIT_MODEL_ID, + }); + }); + + test('given a v2 request in flight then the state says which routes it comes from', () => { + mockFetchV2ModelCatalogMetadata.mockReturnValue(new Promise(() => {})); + const store = testStore(); + + store.dispatch(fetchMetadataThunk(metadataRequest('us', 'v2'))); + + expect(store.getState().metadata.loading).toBe(true); + expect(store.getState().metadata.routes).toBe('v2'); + }); + + test('given v1 metadata in flight then a v2 request for the same country still runs', async () => { + // Given + mockFetchV1Metadata.mockReturnValue(new Promise(() => {})); + mockFetchV2ModelCatalogMetadata.mockResolvedValue(v2Payload()); + const store = testStore(); + store.dispatch(fetchMetadataThunk('us')); + + // When + await store.dispatch(fetchMetadataThunk(metadataRequest('us', 'v2'))); + + // Then + expect(store.getState().metadata.routes).toBe('v2'); + expect(store.getState().metadata.version).toBe(TEST_POLICYENGINE_VERSION); + }); + + test('given a failed v2 request then the error is kept', async () => { + mockFetchV2ModelCatalogMetadata.mockRejectedValue(new Error('The catalog is not published')); + const store = testStore(); + + await store.dispatch(fetchMetadataThunk(metadataRequest('us', 'v2'))); + + expect(store.getState().metadata.error).toBe('The catalog is not published'); + expect(store.getState().metadata.loading).toBe(false); + }); +}); diff --git a/app/src/types/metadata.ts b/app/src/types/metadata.ts index c4476d78f..1481c223f 100644 --- a/app/src/types/metadata.ts +++ b/app/src/types/metadata.ts @@ -19,6 +19,24 @@ export interface MetadataRegionEntry { state_name?: string; } +/** + * Which API routes model metadata was loaded from: v1's + * `GET /{country}/metadata`, or the Public API's v2 catalog routes, which v3 + * reads on demand. v3's choice between them (V3ApiRoutes in + * libs/flagship/v3ApiRoutes) is the same type. + */ +export type MetadataRoutes = 'v1' | 'v2'; + +/** + * The v2 catalog version metadata came from, for what v3 creates against + * it (`POST /v2/policies` takes the tax-benefit model's catalog ID). + */ +export interface V2CatalogReference { + /** The PolicyEngine.py version the catalog version belongs to. */ + policyengineVersion: string; + taxBenefitModelId: string; +} + export interface MetadataApiPayload { status: string; message: string | null; @@ -40,6 +58,8 @@ export interface MetadataApiPayload { }; version: string; spm?: SPMMetadata; + /** Set only when the metadata came from the v2 routes. */ + v2Catalog?: V2CatalogReference; }; } @@ -85,6 +105,10 @@ export interface MetadataState { }; version: string | null; spm?: SPMMetadata; + /** The routes the current (or pending) metadata comes from; absent means v1. */ + routes?: MetadataRoutes; + /** The v2 catalog version, when the metadata came from the v2 routes. */ + v2Catalog?: V2CatalogReference | null; // Computed parameter tree for policy creation UI (built when metadata is fetched) parameterTree: ParameterTreeNode | null; diff --git a/calculator-app/src/app/[countryId]/providers.tsx b/calculator-app/src/app/[countryId]/providers.tsx index f2c0d6436..307d1bd99 100644 --- a/calculator-app/src/app/[countryId]/providers.tsx +++ b/calculator-app/src/app/[countryId]/providers.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import dynamic from "next/dynamic"; +import { useSelectedLayoutSegment } from "next/navigation"; import { QueryNormalizerProvider } from "@normy/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Provider, useDispatch } from "react-redux"; @@ -10,6 +11,7 @@ import { AppProvider } from "@/contexts/AppContext"; import { CalcOrchestratorProvider } from "@/contexts/CalcOrchestratorContext"; import { useCurrentCountry } from "@/hooks/useCurrentCountry"; import { useFetchMetadata } from "@/hooks/useMetadata"; +import { metadataRoutesForRouteGroup } from "@/libs/flagship/v3ApiRoutes"; import { setCurrentCountry } from "@/reducers/metadataReducer"; import { AppDispatch, store } from "@/store"; import { cacheMonitor } from "@/utils/cacheMonitor"; @@ -87,16 +89,19 @@ export function CalculatorProviders({ /** * Syncs the country to Redux and triggers a non-blocking metadata fetch. + * The v3 workspace route group loads from the routes V3_API_ROUTES names; + * the older report builder always loads from v1. */ function MetadataBootstrap({ children }: { children: React.ReactNode }) { const countryId = useCurrentCountry(); const dispatch = useDispatch(); + const routes = metadataRoutesForRouteGroup(useSelectedLayoutSegment()); useEffect(() => { dispatch(setCurrentCountry(countryId)); }, [countryId, dispatch]); - useFetchMetadata(countryId); + useFetchMetadata(countryId, routes); return <>{children}; } From a24cae70963154778bef73ec7336eb9c3dbd4949 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:10:36 +0400 Subject: [PATCH 03/28] Build v3's parameter search from the catalog's search file on the v2 routes On the v2 routes v3 does not download the whole metadata, so its search, add bar and draft editor need another source for which parameters a reform can take. That source is the catalog version's search file (`GET /v2/parameters/search-file`), named by version so the browser can keep it. - `fetchParameterSearchFile` reads the file and refuses a format this app does not read. - `buildV2SearchCatalog` turns it into the collection v3's existing search builders take (unchanged): v1's fallback labels for unlabelled parameters and nodes ("bracket N" for a bracket), the parameters a reform cannot change kept for their labels only, and no values (they load on demand). Which parameters a draft can take comes from each entry's editability for today's date. It is built once per file and date. - `useV3SearchCatalog` gives search entries, the index, the addable paths, concept clusters, labels and a status: from the whole metadata on the v1 routes, from the search file on the v2 routes, where it stays 'loading' until the file arrives and 'error' with a retry if it fails. `useV3SearchFileInBackground` starts the download once a page renders. Nothing reads it yet; the constant `V3_API_ROUTES` is still 'v1'. Co-Authored-By: Claude Opus 5.5 --- app/src/api/publicApi/client.ts | 26 ++- app/src/api/publicApi/searchFile.ts | 44 +++++ app/src/hooks/useV3SearchCatalog.ts | 185 ++++++++++++++++++ app/src/libs/flagship/v2ParameterSearch.ts | 165 ++++++++++++++++ .../api/publicApi/publicApiFetchStub.ts | 45 +++++ .../fixtures/api/publicApi/searchFileMocks.ts | 74 +++++++ .../fixtures/hooks/v3MetadataWrapper.tsx | 29 +++ .../unit/api/publicApi/searchFile.test.ts | 48 +++++ .../unit/hooks/useV3SearchCatalogV2.test.tsx | 94 +++++++++ .../libs/flagship/v2ParameterSearch.test.ts | 121 ++++++++++++ 10 files changed, 823 insertions(+), 8 deletions(-) create mode 100644 app/src/api/publicApi/searchFile.ts create mode 100644 app/src/hooks/useV3SearchCatalog.ts create mode 100644 app/src/libs/flagship/v2ParameterSearch.ts create mode 100644 app/src/tests/fixtures/api/publicApi/publicApiFetchStub.ts create mode 100644 app/src/tests/fixtures/api/publicApi/searchFileMocks.ts create mode 100644 app/src/tests/fixtures/hooks/v3MetadataWrapper.tsx create mode 100644 app/src/tests/unit/api/publicApi/searchFile.test.ts create mode 100644 app/src/tests/unit/hooks/useV3SearchCatalogV2.test.tsx create mode 100644 app/src/tests/unit/libs/flagship/v2ParameterSearch.test.ts diff --git a/app/src/api/publicApi/client.ts b/app/src/api/publicApi/client.ts index 3f3bcad47..0a40dded4 100644 --- a/app/src/api/publicApi/client.ts +++ b/app/src/api/publicApi/client.ts @@ -51,14 +51,13 @@ interface ClientOutcome { } /** - * The `result` of a v2 success envelope (`{status: "ok", message, result}`). - * A failed response throws V2ApiError with its status and the API's message, - * and a request that never reached the API throws status 0. + * The body of a successful response, for routes that return a document + * without the v2 envelope (such as the parameter search file). A failed + * response throws V2ApiError with its status and the API's message, and a + * request that never reached the API throws status 0. */ -export async function requireResult( - call: Promise> -): Promise { - let outcome: ClientOutcome<{ result: Result }>; +export async function requireBody(call: Promise>): Promise { + let outcome: ClientOutcome; try { outcome = await call; } catch (error) { @@ -78,5 +77,16 @@ export async function requireResult( : null; throw new V2ApiError(response.status, failureMessage(response.status, apiMessage)); } - return data.result; + return data; +} + +/** + * The `result` of a v2 success envelope (`{status: "ok", message, result}`). + * A failed response throws V2ApiError with its status and the API's message, + * and a request that never reached the API throws status 0. + */ +export async function requireResult( + call: Promise> +): Promise { + return (await requireBody(call)).result; } diff --git a/app/src/api/publicApi/searchFile.ts b/app/src/api/publicApi/searchFile.ts new file mode 100644 index 000000000..0f6310eba --- /dev/null +++ b/app/src/api/publicApi/searchFile.ts @@ -0,0 +1,44 @@ +import { createPublicApiClient, requireBody, type PublicApiClient } from '@/api/publicApi/client'; +import type { components } from '@/api/publicApi/schema'; + +/** + * One catalog version's parameter search file (`GET /v2/parameters/search-file`): + * every parameter a reform can change, with its label, unit, description + * and whether a draft can edit its value by date; the labels of parameters + * a reform cannot change; and every labelled node. It carries no values. + * v3 searches it in its existing web worker (see libs/flagship/v2ParameterSearch). + */ +export type ParameterSearchFile = components['schemas']['MetadataParameterSearchFileContent']; +export type ParameterSearchFileParameter = + components['schemas']['MetadataParameterSearchFileParameter']; + +/** The file format this app reads; a later format needs code that reads it. */ +export const SUPPORTED_SEARCH_FILE_FORMAT = 1; + +/** + * The search file for a catalog version. Naming the version lets the + * browser keep the response (the API marks a named version's file as + * long-lived, and it arrives gzip-encoded). + */ +export async function fetchParameterSearchFile( + countryId: string, + policyengineVersion: string, + client: PublicApiClient = createPublicApiClient() +): Promise { + const file = await requireBody( + client.GET('/v2/parameters/search-file', { + params: { + query: { + country_id: countryId as 'us' | 'uk', + policyengine_version: policyengineVersion, + }, + }, + }) + ); + if (file.format !== SUPPORTED_SEARCH_FILE_FORMAT) { + throw new Error( + `The Public API sent search file format ${file.format}; this app reads format ${SUPPORTED_SEARCH_FILE_FORMAT}.` + ); + } + return file; +} diff --git a/app/src/hooks/useV3SearchCatalog.ts b/app/src/hooks/useV3SearchCatalog.ts new file mode 100644 index 000000000..da07e4d2f --- /dev/null +++ b/app/src/hooks/useV3SearchCatalog.ts @@ -0,0 +1,185 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useSelector } from 'react-redux'; +import { fetchParameterSearchFile } from '@/api/publicApi/searchFile'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; +import { buildV2SearchCatalog } from '@/libs/flagship/v2ParameterSearch'; +import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; +import { + createParameterSearchIndex, + selectAddableParameterPaths, + selectConceptClusters, + selectParameterEntriesByPath, + selectParameterSearchEntries, + selectParameterSearchIndex, + type ParameterSearchEntry, + type ParameterSearchIndex, +} from '@/libs/parameterSearch'; +import type { RootState } from '@/store'; + +/** + * What v3's parameter search, add bar and draft editor read about which + * parameters a reform can take: search entries and their index, the + * addable paths, concept clusters, and each path's label. + * + * On the v1 routes these come from the whole metadata (as before). On the + * v2 routes they come from the catalog version's search file, which loads + * in the background once the small model catalog is in; until it arrives + * the status is 'loading' and nothing is offered. + */ +export interface V3SearchCatalog { + status: 'loading' | 'error' | 'ready'; + /** The failure's message, when the status is 'error'. */ + message: string | null; + retry: () => unknown; + entries: ParameterSearchEntry[]; + index: ParameterSearchIndex; + entriesByPath: Map; + addablePaths: Set; + clusters: string[][]; + /** A parameter's or node's own label, or null when it has none. */ + labelFor: (path: string) => string | null; +} + +const NO_ENTRIES: ParameterSearchEntry[] = []; +const NO_INDEX: ParameterSearchIndex = createParameterSearchIndex([]); +const NO_ENTRIES_BY_PATH = new Map(); +const NO_PATHS = new Set(); +const NO_CLUSTERS: string[][] = []; + +/** The query key of a catalog version's search file. */ +export function v2SearchFileKey(countryId: string, policyengineVersion: string | null | undefined) { + return ['v2-parameter-search-file', countryId, policyengineVersion] as const; +} + +/** A catalog version's file never changes, so it is fetched once per session. */ +const SEARCH_FILE_QUERY_OPTIONS = { staleTime: Infinity, gcTime: Infinity } as const; + +function useV1SearchCatalog(): V3SearchCatalog { + const metadata = useFlagshipMetadataStatus(); + const entries = useSelector(selectParameterSearchEntries); + const index = useSelector(selectParameterSearchIndex); + const entriesByPath = useSelector(selectParameterEntriesByPath); + const addablePaths = useSelector(selectAddableParameterPaths); + const clusters = useSelector(selectConceptClusters); + const parameters = useSelector((state: RootState) => state.metadata.parameters); + const labelFor = useCallback((path: string) => parameters?.[path]?.label ?? null, [parameters]); + const status = entries.length > 0 ? 'ready' : metadata.status === 'error' ? 'error' : 'loading'; + return { + status, + message: status === 'error' ? metadata.message : null, + retry: metadata.retry, + entries, + index, + entriesByPath, + addablePaths, + clusters, + labelFor, + }; +} + +/** The current catalog version's PolicyEngine.py version, once the model catalog is in. */ +function useV2CatalogVersion(): string | null { + return useSelector((state: RootState) => + state.metadata.routes === 'v2' ? (state.metadata.v2Catalog?.policyengineVersion ?? null) : null + ); +} + +function useV2SearchCatalog(): V3SearchCatalog { + const countryId = useCurrentCountry(); + const metadata = useFlagshipMetadataStatus(); + const policyengineVersion = useV2CatalogVersion(); + const query = useQuery({ + queryKey: v2SearchFileKey(countryId, policyengineVersion), + queryFn: () => fetchParameterSearchFile(countryId, policyengineVersion!), + enabled: policyengineVersion !== null, + ...SEARCH_FILE_QUERY_OPTIONS, + }); + const catalog = useMemo( + () => (query.data ? buildV2SearchCatalog(query.data) : null), + [query.data] + ); + const labelFor = useCallback( + (path: string) => catalog?.collection[path]?.label ?? null, + [catalog] + ); + + if (metadata.status === 'error') { + return emptyCatalog('error', metadata.message, metadata.retry, labelFor); + } + if (query.isError) { + return emptyCatalog( + 'error', + query.error instanceof Error ? query.error.message : null, + query.refetch, + labelFor + ); + } + if (!catalog) { + return emptyCatalog('loading', null, query.refetch, labelFor); + } + return { + status: 'ready', + message: null, + retry: query.refetch, + entries: catalog.entries, + index: catalog.index, + entriesByPath: catalog.entriesByPath, + addablePaths: catalog.addablePaths, + clusters: catalog.clusters, + labelFor, + }; +} + +function emptyCatalog( + status: 'loading' | 'error', + message: string | null, + retry: () => unknown, + labelFor: (path: string) => string | null +): V3SearchCatalog { + return { + status, + message, + retry, + entries: NO_ENTRIES, + index: NO_INDEX, + entriesByPath: NO_ENTRIES_BY_PATH, + addablePaths: NO_PATHS, + clusters: NO_CLUSTERS, + labelFor, + }; +} + +/** + * The search catalog for the routes v3 uses (V3_API_ROUTES, a constant, so + * every render calls the same hooks). + */ +export const useV3SearchCatalog: () => V3SearchCatalog = + V3_API_ROUTES === 'v2' ? useV2SearchCatalog : useV1SearchCatalog; + +/** + * Starts the search file's download once a v3 page has rendered, so it is + * in by the time someone searches. Nothing on the v1 routes, where the + * whole metadata already holds what search reads. + */ +function useV2SearchFileInBackground(): void { + const countryId = useCurrentCountry(); + const policyengineVersion = useV2CatalogVersion(); + const queryClient = useQueryClient(); + useEffect(() => { + if (policyengineVersion === null) { + return; + } + void queryClient.prefetchQuery({ + queryKey: v2SearchFileKey(countryId, policyengineVersion), + queryFn: () => fetchParameterSearchFile(countryId, policyengineVersion), + ...SEARCH_FILE_QUERY_OPTIONS, + }); + }, [countryId, policyengineVersion, queryClient]); +} + +function noBackgroundLoad(): void {} + +export const useV3SearchFileInBackground: () => void = + V3_API_ROUTES === 'v2' ? useV2SearchFileInBackground : noBackgroundLoad; diff --git a/app/src/libs/flagship/v2ParameterSearch.ts b/app/src/libs/flagship/v2ParameterSearch.ts new file mode 100644 index 000000000..56638fc07 --- /dev/null +++ b/app/src/libs/flagship/v2ParameterSearch.ts @@ -0,0 +1,165 @@ +import type { ParameterSearchFile, ParameterSearchFileParameter } from '@/api/publicApi/searchFile'; +import { + buildConceptAliases, + buildConceptClusters, + buildParameterSearchEntries, + buildStateNames, + createParameterSearchIndex, + type ParameterSearchEntry, + type ParameterSearchIndex, +} from '@/libs/parameterSearch'; +import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; + +/** + * v3's parameter search on the v2 routes: the search file of the catalog + * version, turned into the collection v3's existing search builders read + * (`libs/parameterSearch`, unchanged), so the same entries, clusters, + * aliases and index come out as from v1's whole metadata. + * + * The collection holds labels, units and descriptions only: values are not + * in the file and load on demand. Which parameters a draft can take comes + * from the file's `editable` and `editable_changes`, for a given date. + */ + +/** + * v1's label for a parameter or node without one: "bracket N" for a + * bracket, otherwise the last part of its path with spaces for underscores. + */ +export function v1FallbackLabel(path: string): string { + const bracket = /\[(\d+)\]$/.exec(path); + if (bracket) { + return `bracket ${Number(bracket[1]) + 1}`; + } + return (path.split('.').pop() ?? path).replaceAll('_', ' '); +} + +/** Every node above `path`: `a.b[0].c` has `a`, `a.b`, `a.b[0]`. */ +function ancestorPaths(path: string): string[] { + const ancestors: string[] = []; + for (let index = 1; index < path.length; index += 1) { + if (path[index] === '.' || path[index] === '[') { + ancestors.push(path.slice(0, index)); + } + } + return ancestors; +} + +/** + * The search file as a parameter collection: each parameter a reform can + * change (economy and household set, as the file lists only those), each + * parameter a reform cannot change (flags off, so search leaves it out but + * its label still feeds concept clusters and aliases), every labelled node, + * and every unlabelled node above a parameter with v1's fallback label, as + * v1's metadata listed every node. + */ +export function searchCollectionFromFile(file: ParameterSearchFile): ParameterMetadataCollection { + const collection: ParameterMetadataCollection = {}; + for (const node of file.nodes) { + collection[node.path] = { type: 'parameterNode', parameter: node.path, label: node.label }; + } + for (const parameter of file.parameters) { + collection[parameter.path] = { + type: 'parameter', + parameter: parameter.path, + label: parameter.label ?? v1FallbackLabel(parameter.path), + unit: parameter.unit, + description: parameter.description, + economy: true, + household: true, + }; + } + for (const parameter of file.other_parameters) { + collection[parameter.path] = { + type: 'parameter', + parameter: parameter.path, + label: parameter.label ?? v1FallbackLabel(parameter.path), + economy: false, + household: false, + }; + } + for (const path of Object.keys(collection)) { + for (const ancestor of ancestorPaths(path)) { + if (!collection[ancestor]) { + collection[ancestor] = { + type: 'parameterNode', + parameter: ancestor, + label: v1FallbackLabel(ancestor), + }; + } + } + } + return collection; +} + +/** Whether a draft can edit the parameter's current-law value on `date` (YYYY-MM-DD). */ +export function editableOn(parameter: ParameterSearchFileParameter, date: string): boolean { + let editable = parameter.editable; + const changes = [...(parameter.editable_changes ?? [])].sort((first, second) => + first.start_date.localeCompare(second.start_date) + ); + for (const change of changes) { + if (change.start_date.slice(0, 10) <= date) { + editable = change.editable; + } + } + return editable; +} + +/** What v3's search and draft editor read, built from one search file for one date. */ +export interface V2SearchCatalog { + /** Labels, units and descriptions of every parameter and node in the file. */ + collection: ParameterMetadataCollection; + /** The parameters a draft can take on the date, as search entries. */ + entries: ParameterSearchEntry[]; + index: ParameterSearchIndex; + entriesByPath: Map; + addablePaths: Set; + clusters: string[][]; +} + +const catalogsByFile = new WeakMap>(); + +/** Today, as the date part of an ISO timestamp. */ +export function todayIsoDate(): string { + return new Date().toISOString().slice(0, 10); +} + +/** + * The search catalog for a file on a date, built once per file and date + * (the file is large, so every reader shares one build). + */ +export function buildV2SearchCatalog( + file: ParameterSearchFile, + date: string = todayIsoDate() +): V2SearchCatalog { + const byDate = catalogsByFile.get(file) ?? new Map(); + catalogsByFile.set(file, byDate); + const built = byDate.get(date); + if (built) { + return built; + } + + const collection = searchCollectionFromFile(file); + const editable = new Set( + file.parameters.filter((parameter) => editableOn(parameter, date)).map((entry) => entry.path) + ); + const entries = buildParameterSearchEntries(collection).filter((entry) => + editable.has(entry.path) + ); + const clusters = buildConceptClusters(collection); + const catalog: V2SearchCatalog = { + collection, + entries, + index: createParameterSearchIndex( + entries, + clusters, + buildConceptAliases(collection), + buildStateNames(collection) + ), + entriesByPath: new Map(entries.map((entry) => [entry.path, entry])), + addablePaths: new Set(entries.map((entry) => entry.path)), + clusters, + }; + byDate.set(date, catalog); + return catalog; +} diff --git a/app/src/tests/fixtures/api/publicApi/publicApiFetchStub.ts b/app/src/tests/fixtures/api/publicApi/publicApiFetchStub.ts new file mode 100644 index 000000000..024836976 --- /dev/null +++ b/app/src/tests/fixtures/api/publicApi/publicApiFetchStub.ts @@ -0,0 +1,45 @@ +import { vi } from 'vitest'; +import { modelCatalogEnvelope } from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; + +export type RouteHandler = (url: URL, request: Request) => Response | Promise; + +export function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +/** + * Stubs the global fetch the Public API client uses, answering each v2 + * route from `handlers` by path; `/v2/model-catalog` answers with the US + * fixture unless a handler replaces it. Returns the stub, so tests can + * count the requests each path received. + */ +export function stubPublicApiFetch(handlers: Record = {}) { + const routes: Record = { + '/v2/model-catalog': () => jsonResponse(modelCatalogEnvelope()), + ...handlers, + }; + const fetchStub = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(String(input), init); + const url = new URL(request.url); + const handler = routes[url.pathname]; + if (!handler) { + return jsonResponse({ status: 'error', message: `No stub for ${url.pathname}` }, 404); + } + return handler(url, request); + }); + vi.stubGlobal('fetch', fetchStub); + return fetchStub; +} + +/** The requests the stub received for one path. */ +export function requestsTo(fetchStub: ReturnType, path: string): URL[] { + return fetchStub.mock.calls + .map( + ([input, init]) => + new URL(input instanceof Request ? input.url : new Request(String(input), init).url) + ) + .filter((url) => url.pathname === path); +} diff --git a/app/src/tests/fixtures/api/publicApi/searchFileMocks.ts b/app/src/tests/fixtures/api/publicApi/searchFileMocks.ts new file mode 100644 index 000000000..643a3073d --- /dev/null +++ b/app/src/tests/fixtures/api/publicApi/searchFileMocks.ts @@ -0,0 +1,74 @@ +import type { components } from '@/api/publicApi/schema'; +import { TEST_POLICYENGINE_VERSION } from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; + +type SearchFile = components['schemas']['MetadataParameterSearchFileContent']; + +export const STANDARD_DEDUCTION_SINGLE = 'gov.irs.deductions.standard.amount.SINGLE'; +export const STANDARD_DEDUCTION_JOINT = 'gov.irs.deductions.standard.amount.JOINT'; +export const CTC_FIRST_BRACKET_AMOUNT = 'gov.irs.credits.ctc.amount.base[0].amount'; +export const CTC_FULLY_REFUNDABLE = 'gov.irs.credits.ctc.refundable.fully_refundable'; +/** Not editable until 2026, when its value becomes a number. */ +export const CTC_PHASE_OUT_SINGLE = 'gov.irs.credits.ctc.phase_out.threshold.SINGLE'; +/** A parameter a reform cannot change: in the file for its label only. */ +export const CTC_ARPA_AMOUNT = 'gov.irs.credits.ctc.amount.arpa'; + +/** A small US search file, as `GET /v2/parameters/search-file` returns it. */ +export const US_SEARCH_FILE: SearchFile = { + format: 1, + country_id: 'us', + policyengine_version: TEST_POLICYENGINE_VERSION, + parameters: [ + { + path: CTC_FIRST_BRACKET_AMOUNT, + label: null, + unit: 'currency-USD', + description: 'Child Tax Credit base amount for the first bracket.', + editable: true, + }, + { + path: CTC_PHASE_OUT_SINGLE, + label: 'CTC phase-out threshold for single filers', + unit: 'currency-USD', + description: null, + editable: false, + editable_changes: [{ start_date: '2026-01-01', editable: true }], + }, + { + path: CTC_FULLY_REFUNDABLE, + label: 'Child Tax Credit fully refundable', + unit: 'bool', + description: null, + editable: true, + }, + { + path: STANDARD_DEDUCTION_JOINT, + label: null, + unit: 'currency-USD', + description: null, + editable: true, + }, + { + path: STANDARD_DEDUCTION_SINGLE, + label: null, + unit: 'currency-USD', + description: 'Standard deduction for single filers.', + editable: true, + }, + ], + other_parameters: [{ path: CTC_ARPA_AMOUNT, label: 'ARPA Child Tax Credit amount' }], + nodes: [ + { path: 'gov', label: 'Government' }, + { path: 'gov.irs', label: 'IRS' }, + { path: 'gov.irs.credits.ctc', label: 'Child Tax Credit' }, + { path: 'gov.irs.credits.ctc.amount.base', label: 'Child Tax Credit base amount' }, + { path: 'gov.irs.deductions.standard', label: 'Standard deduction' }, + { path: 'gov.irs.deductions.standard.amount', label: 'Standard deduction amount' }, + ], +}; + +export function searchFileResponse(file: unknown = US_SEARCH_FILE, status = 200): Response { + return new Response(JSON.stringify(file), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/app/src/tests/fixtures/hooks/v3MetadataWrapper.tsx b/app/src/tests/fixtures/hooks/v3MetadataWrapper.tsx new file mode 100644 index 000000000..1e1aacefa --- /dev/null +++ b/app/src/tests/fixtures/hooks/v3MetadataWrapper.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Provider } from 'react-redux'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { clearMetadata, fetchMetadataThunk, metadataRequest } from '@/reducers/metadataReducer'; +import { store } from '@/store'; + +/** A fresh query client per test, retrying nothing, so a failure shows at once. */ +export function testQueryClient(): QueryClient { + return new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); +} + +export function v3Wrapper(queryClient: QueryClient = testQueryClient()) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); + }; +} + +/** The store with v2 model metadata loaded, as v3 has it after the switch. */ +export async function seedV2ModelCatalog(): Promise { + store.dispatch(clearMetadata()); + await store.dispatch(fetchMetadataThunk(metadataRequest('us', 'v2'))); +} diff --git a/app/src/tests/unit/api/publicApi/searchFile.test.ts b/app/src/tests/unit/api/publicApi/searchFile.test.ts new file mode 100644 index 000000000..65719628f --- /dev/null +++ b/app/src/tests/unit/api/publicApi/searchFile.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test, vi } from 'vitest'; +import { createPublicApiClient } from '@/api/publicApi/client'; +import { fetchParameterSearchFile } from '@/api/publicApi/searchFile'; +import { TEST_POLICYENGINE_VERSION } from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; +import { searchFileResponse, US_SEARCH_FILE } from '@/tests/fixtures/api/publicApi/searchFileMocks'; +import { errorResponse, TEST_API_BASE } from '@/tests/fixtures/api/v2UserMocks'; + +function clientReturning(response: Response) { + const fetchMock = vi.fn().mockResolvedValue(response); + const client = createPublicApiClient({ + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + return { client, fetchMock }; +} + +describe('fetchParameterSearchFile', () => { + test('given a catalog version then it asks for that version, so the response can be cached', async () => { + // Given + const { client, fetchMock } = clientReturning(searchFileResponse()); + + // When + const file = await fetchParameterSearchFile('us', TEST_POLICYENGINE_VERSION, client); + + // Then + const request = fetchMock.mock.calls[0][0] as Request; + expect(request.url).toBe( + `${TEST_API_BASE}/v2/parameters/search-file?country_id=us&policyengine_version=${TEST_POLICYENGINE_VERSION}` + ); + expect(file).toEqual(US_SEARCH_FILE); + }); + + test('given a file in a format this app does not read then it refuses it', async () => { + const { client } = clientReturning(searchFileResponse({ ...US_SEARCH_FILE, format: 2 })); + + await expect(fetchParameterSearchFile('us', TEST_POLICYENGINE_VERSION, client)).rejects.toThrow( + 'search file format 2' + ); + }); + + test('given a version without a search file then it rejects with the API status', async () => { + const { client } = clientReturning(errorResponse(503, 'No search file for this version')); + + await expect( + fetchParameterSearchFile('us', TEST_POLICYENGINE_VERSION, client) + ).rejects.toMatchObject({ status: 503 }); + }); +}); diff --git a/app/src/tests/unit/hooks/useV3SearchCatalogV2.test.tsx b/app/src/tests/unit/hooks/useV3SearchCatalogV2.test.tsx new file mode 100644 index 000000000..db9f4186c --- /dev/null +++ b/app/src/tests/unit/hooks/useV3SearchCatalogV2.test.tsx @@ -0,0 +1,94 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { useV3SearchCatalog } from '@/hooks/useV3SearchCatalog'; +import { TEST_POLICYENGINE_VERSION } from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; +import { + jsonResponse, + requestsTo, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { + STANDARD_DEDUCTION_SINGLE, + US_SEARCH_FILE, +} from '@/tests/fixtures/api/publicApi/searchFileMocks'; +import { seedV2ModelCatalog, v3Wrapper } from '@/tests/fixtures/hooks/v3MetadataWrapper'; + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +const SEARCH_FILE_PATH = '/v2/parameters/search-file'; + +describe('useV3SearchCatalog with v3 on the v2 routes', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('given the search file is published', () => { + let fetchStub: ReturnType; + beforeEach(async () => { + fetchStub = stubPublicApiFetch({ [SEARCH_FILE_PATH]: () => jsonResponse(US_SEARCH_FILE) }); + await seedV2ModelCatalog(); + }); + + test('then the file loads once, for the catalog version', async () => { + const { result } = renderHook(() => useV3SearchCatalog(), { wrapper: v3Wrapper() }); + + expect(result.current.status).toBe('loading'); + await waitFor(() => expect(result.current.status).toBe('ready')); + const requests = requestsTo(fetchStub, SEARCH_FILE_PATH); + expect(requests).toHaveLength(1); + expect(requests[0].searchParams.get('policyengine_version')).toBe(TEST_POLICYENGINE_VERSION); + expect(result.current.addablePaths.has(STANDARD_DEDUCTION_SINGLE)).toBe(true); + expect(result.current.labelFor('gov.irs.deductions.standard')).toBe('Standard deduction'); + }); + }); + + describe('given the file has not arrived', () => { + beforeEach(async () => { + stubPublicApiFetch({ [SEARCH_FILE_PATH]: () => new Promise(() => {}) }); + await seedV2ModelCatalog(); + }); + + test('then nothing is offered yet', () => { + const { result } = renderHook(() => useV3SearchCatalog(), { wrapper: v3Wrapper() }); + + expect(result.current.status).toBe('loading'); + expect(result.current.entries).toEqual([]); + expect(result.current.addablePaths.size).toBe(0); + }); + }); + + describe('given the file fails once', () => { + let fetchStub: ReturnType; + beforeEach(async () => { + let attempts = 0; + fetchStub = stubPublicApiFetch({ + [SEARCH_FILE_PATH]: () => { + attempts += 1; + return attempts === 1 + ? jsonResponse({ status: 'error', message: 'No search file' }, 503) + : jsonResponse(US_SEARCH_FILE); + }, + }); + await seedV2ModelCatalog(); + }); + + test('then the status says so and retry asks again', async () => { + // Given + const { result } = renderHook(() => useV3SearchCatalog(), { wrapper: v3Wrapper() }); + await waitFor(() => expect(result.current.status).toBe('error')); + + // When + act(() => { + result.current.retry(); + }); + + // Then + await waitFor(() => expect(result.current.status).toBe('ready')); + expect(requestsTo(fetchStub, SEARCH_FILE_PATH)).toHaveLength(2); + }); + }); +}); diff --git a/app/src/tests/unit/libs/flagship/v2ParameterSearch.test.ts b/app/src/tests/unit/libs/flagship/v2ParameterSearch.test.ts new file mode 100644 index 000000000..b0f2fe805 --- /dev/null +++ b/app/src/tests/unit/libs/flagship/v2ParameterSearch.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from 'vitest'; +import { + buildV2SearchCatalog, + editableOn, + searchCollectionFromFile, +} from '@/libs/flagship/v2ParameterSearch'; +import { DEFAULT_SEARCH_FILTERS, searchParametersWidening } from '@/libs/parameterSearch'; +import { + CTC_ARPA_AMOUNT, + CTC_FIRST_BRACKET_AMOUNT, + CTC_FULLY_REFUNDABLE, + CTC_PHASE_OUT_SINGLE, + STANDARD_DEDUCTION_JOINT, + STANDARD_DEDUCTION_SINGLE, + US_SEARCH_FILE, +} from '@/tests/fixtures/api/publicApi/searchFileMocks'; + +const BEFORE_2026 = '2025-06-30'; +const IN_2026 = '2026-03-01'; + +describe('searchCollectionFromFile', () => { + test('given parameters without a label then they take the label v1 gave them', () => { + const collection = searchCollectionFromFile(US_SEARCH_FILE); + + expect(collection[STANDARD_DEDUCTION_SINGLE].label).toBe('SINGLE'); + expect(collection[CTC_FIRST_BRACKET_AMOUNT].label).toBe('amount'); + expect(collection[CTC_FULLY_REFUNDABLE].label).toBe('Child Tax Credit fully refundable'); + }); + + test('given unlabelled ancestors then each is a node with v1 label, a bracket as "bracket N"', () => { + const collection = searchCollectionFromFile(US_SEARCH_FILE); + + expect(collection['gov.irs.deductions']).toMatchObject({ + type: 'parameterNode', + label: 'deductions', + }); + expect(collection['gov.irs.credits.ctc.amount.base[0]']).toMatchObject({ + type: 'parameterNode', + label: 'bracket 1', + }); + expect(collection['gov.irs.deductions.standard']).toMatchObject({ + type: 'parameterNode', + label: 'Standard deduction', + }); + }); + + test('given the file then it carries no values: they load on demand', () => { + const collection = searchCollectionFromFile(US_SEARCH_FILE); + + expect(Object.values(collection).every((entry) => entry.values === undefined)).toBe(true); + }); + + test('given a parameter a reform cannot change then it is labelled but not searchable', () => { + const collection = searchCollectionFromFile(US_SEARCH_FILE); + + expect(collection[CTC_ARPA_AMOUNT]).toMatchObject({ + label: 'ARPA Child Tax Credit amount', + economy: false, + household: false, + }); + }); +}); + +describe('editableOn', () => { + test('given a parameter whose value becomes editable later then the date decides', () => { + const phaseOut = US_SEARCH_FILE.parameters.find( + (entry) => entry.path === CTC_PHASE_OUT_SINGLE + )!; + + expect(editableOn(phaseOut, BEFORE_2026)).toBe(false); + expect(editableOn(phaseOut, IN_2026)).toBe(true); + expect(editableOn(phaseOut, '2026-01-01')).toBe(true); + }); +}); + +describe('buildV2SearchCatalog', () => { + test('given a date then only parameters a draft can edit then are offered', () => { + const before = buildV2SearchCatalog(US_SEARCH_FILE, BEFORE_2026); + const during = buildV2SearchCatalog(US_SEARCH_FILE, IN_2026); + + expect(before.addablePaths.has(CTC_PHASE_OUT_SINGLE)).toBe(false); + expect(during.addablePaths.has(CTC_PHASE_OUT_SINGLE)).toBe(true); + expect(before.addablePaths.has(CTC_ARPA_AMOUNT)).toBe(false); + expect([...before.addablePaths].sort()).toEqual( + [ + CTC_FIRST_BRACKET_AMOUNT, + CTC_FULLY_REFUNDABLE, + STANDARD_DEDUCTION_JOINT, + STANDARD_DEDUCTION_SINGLE, + ].sort() + ); + }); + + test('given the catalog then entries carry breadcrumbs from the node labels', () => { + const catalog = buildV2SearchCatalog(US_SEARCH_FILE, IN_2026); + + const entry = catalog.entriesByPath.get(STANDARD_DEDUCTION_SINGLE)!; + expect(entry.breadcrumb).toContain('Standard deduction'); + expect(entry.unit).toBe('currency-USD'); + expect(entry.description).toBe('Standard deduction for single filers.'); + }); + + test('given a query then v3 search finds it in the index built from the file', () => { + const catalog = buildV2SearchCatalog(US_SEARCH_FILE, IN_2026); + + const result = searchParametersWidening( + catalog.index, + 'standard deduction', + 10, + DEFAULT_SEARCH_FILTERS + ); + + expect(result.entries.map((entry) => entry.path)).toContain(STANDARD_DEDUCTION_SINGLE); + }); + + test('given the same file and date twice then the catalog is built once', () => { + expect(buildV2SearchCatalog(US_SEARCH_FILE, IN_2026)).toBe( + buildV2SearchCatalog(US_SEARCH_FILE, IN_2026) + ); + }); +}); From 8cf52319f2a4e84d9c2da5f584e2aa39c66e889b Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:13:41 +0400 Subject: [PATCH 04/28] Load v3's parameters on demand from /v2/model-catalog/parameters On the v2 routes the metadata store starts with no parameters. Views now have a way to name the parameters they show and have them load: - `fetchCatalogParameters` asks `GET /v2/model-catalog/parameters` for up to 100 names a request, at the catalog version, and returns each parameter in v1's shape (values dated as v1 dated them, v1's fallback label where none is stored), the labels of every node above them for breadcrumbs, each parameter's catalog ID and the names the version does not have. - The store's `catalogParametersLoaded` merges them, for the current country and catalog version only. - `useEnsureV3Parameters(names)` loads what the store lacks in one request and reports loading, error (with a retry) or ready; names already loaded, or that the version does not have, are not asked for again. `useLoadV3Parameters` returns a function that resolves once names are loaded, for actions such as adding a parameter. On the v1 routes, where the whole metadata holds every parameter, both report ready with the metadata and send nothing. `V3_API_ROUTES` is still 'v1', so users reach none of this. Co-Authored-By: Claude Opus 5.5 --- app/src/api/publicApi/catalogParameters.ts | 101 +++++++++++ app/src/hooks/useV3Parameters.ts | 155 +++++++++++++++++ app/src/reducers/metadataReducer.ts | 35 +++- .../api/publicApi/catalogParametersMocks.ts | 64 +++++++ .../api/publicApi/catalogParameters.test.ts | 119 +++++++++++++ .../unit/hooks/useV3ParametersV1.test.tsx | 40 +++++ .../unit/hooks/useV3ParametersV2.test.tsx | 160 ++++++++++++++++++ .../unit/reducers/metadataReducerV2.test.ts | 57 ++++++- app/src/types/metadata.ts | 7 + 9 files changed, 736 insertions(+), 2 deletions(-) create mode 100644 app/src/api/publicApi/catalogParameters.ts create mode 100644 app/src/hooks/useV3Parameters.ts create mode 100644 app/src/tests/fixtures/api/publicApi/catalogParametersMocks.ts create mode 100644 app/src/tests/unit/api/publicApi/catalogParameters.test.ts create mode 100644 app/src/tests/unit/hooks/useV3ParametersV1.test.tsx create mode 100644 app/src/tests/unit/hooks/useV3ParametersV2.test.tsx diff --git a/app/src/api/publicApi/catalogParameters.ts b/app/src/api/publicApi/catalogParameters.ts new file mode 100644 index 000000000..2bd57b6c2 --- /dev/null +++ b/app/src/api/publicApi/catalogParameters.ts @@ -0,0 +1,101 @@ +import { createPublicApiClient, requireResult, type PublicApiClient } from '@/api/publicApi/client'; +import type { components } from '@/api/publicApi/schema'; +import { v1FallbackLabel } from '@/libs/flagship/v2ParameterSearch'; +import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; +import type { ValuesList } from '@/types/subIngredients/valueInterval'; + +/** + * Parameters fetched by name from the Public API's + * `GET /v2/model-catalog/parameters`, with their current-law values and the + * labels of every node above them (for breadcrumbs), in the shape v1's + * metadata gave each parameter, so code that reads the metadata store + * reads them unchanged. + */ + +type Schemas = components['schemas']; + +/** How many names one request takes (the route's own bound). */ +export const CATALOG_PARAMETER_BATCH = 100; + +export interface LoadedCatalogParameters { + /** The parameters found, and every node above them. */ + parameters: ParameterMetadataCollection; + /** Each found parameter's catalog ID, which `/v2/policies` takes. */ + parameterIds: Record; + /** Names the catalog version does not have. */ + missingNames: string[]; +} + +/** v1 dated a value in force since the earliest date 0000-01-01; Python's earliest is year 1. */ +const V2_EARLIEST_DATE = '0001-01-01'; +const V1_EARLIEST_DATE = '0000-01-01'; + +function v1Values(values: Schemas['MetadataDatedValue'][]): ValuesList { + return Object.fromEntries( + values.map((dated) => { + const date = dated.start_date.slice(0, 10); + return [date === V2_EARLIEST_DATE ? V1_EARLIEST_DATE : date, dated.value]; + }) + ) as ValuesList; +} + +function addResult( + loaded: LoadedCatalogParameters, + result: Schemas['MetadataCatalogParametersResult'] +): void { + for (const [path, label] of Object.entries(result.node_labels)) { + loaded.parameters[path] = { + type: 'parameterNode', + parameter: path, + label: label ?? v1FallbackLabel(path), + }; + } + for (const parameter of result.parameters) { + loaded.parameters[parameter.name] = { + type: 'parameter', + parameter: parameter.name, + label: parameter.label ?? v1FallbackLabel(parameter.name), + description: parameter.description, + unit: parameter.unit, + values: v1Values(parameter.values), + economy: parameter.economy ?? true, + household: parameter.household ?? true, + }; + loaded.parameterIds[parameter.name] = parameter.id; + } + loaded.missingNames.push(...result.missing_names); +} + +/** The named parameters of a catalog version, in batches the route takes. */ +export async function fetchCatalogParameters( + countryId: string, + policyengineVersion: string, + names: readonly string[], + client: PublicApiClient = createPublicApiClient() +): Promise { + const unique = [...new Set(names)].sort(); + const batches: string[][] = []; + for (let start = 0; start < unique.length; start += CATALOG_PARAMETER_BATCH) { + batches.push(unique.slice(start, start + CATALOG_PARAMETER_BATCH)); + } + const results = await Promise.all( + batches.map((batch) => + requireResult( + client.GET('/v2/model-catalog/parameters', { + params: { + query: { + country_id: countryId as 'us' | 'uk', + policyengine_version: policyengineVersion, + name: batch, + }, + }, + }) + ) + ) + ); + const loaded: LoadedCatalogParameters = { parameters: {}, parameterIds: {}, missingNames: [] }; + for (const result of results) { + addResult(loaded, result); + } + return loaded; +} diff --git a/app/src/hooks/useV3Parameters.ts b/app/src/hooks/useV3Parameters.ts new file mode 100644 index 000000000..3b0b2b5fa --- /dev/null +++ b/app/src/hooks/useV3Parameters.ts @@ -0,0 +1,155 @@ +import { useCallback, useMemo } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { fetchCatalogParameters } from '@/api/publicApi/catalogParameters'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; +import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; +import { catalogParametersLoaded } from '@/reducers/metadataReducer'; +import type { AppDispatch, RootState } from '@/store'; + +/** + * Parameters with their current-law values, for the v3 views that read + * them from the metadata store (`state.metadata.parameters`). + * + * On the v1 routes the whole metadata holds every parameter, so they are + * ready once it is. On the v2 routes the store starts with none: a view + * names what it shows (a reform's parameters, a report's changes) and + * those load in one request, with the labels of the nodes above them; the + * view shows its loading state until they are in. Names the catalog + * version does not have count as loaded, so nothing asks for them again. + */ +export interface V3ParametersStatus { + status: 'loading' | 'error' | 'ready'; + /** The failure's message, when the status is 'error'. */ + message: string | null; + retry: () => unknown; +} + +/** How long a batch's query stays cached without a reader; its parameters stay in the store. */ +const PARAMETER_QUERY_GC_TIME = 5 * 60 * 1000; + +function catalogParametersKey( + countryId: string, + policyengineVersion: string | null, + names: readonly string[] +) { + return ['v2-catalog-parameters', countryId, policyengineVersion, ...names] as const; +} + +function selectV2CatalogVersion(state: RootState): string | null { + return state.metadata.routes === 'v2' + ? (state.metadata.v2Catalog?.policyengineVersion ?? null) + : null; +} + +/** Which of `names` the store lacks values for, sorted, each once. */ +function namesToLoad( + parameters: RootState['metadata']['parameters'], + missingNames: readonly string[] | undefined, + names: readonly string[] +): string[] { + const missing = new Set(missingNames ?? []); + return [...new Set(names)] + .filter((name) => parameters[name]?.values === undefined && !missing.has(name)) + .sort(); +} + +async function loadIntoStore( + dispatch: AppDispatch, + countryId: string, + policyengineVersion: string, + names: readonly string[] +): Promise { + const loaded = await fetchCatalogParameters(countryId, policyengineVersion, names); + dispatch(catalogParametersLoaded({ countryId, policyengineVersion, ...loaded })); + return true; +} + +function useV1EnsureParameters(_names: readonly string[]): V3ParametersStatus { + const metadata = useFlagshipMetadataStatus(); + return { status: metadata.status, message: metadata.message, retry: metadata.retry }; +} + +function useV2EnsureParameters(names: readonly string[]): V3ParametersStatus { + const countryId = useCurrentCountry(); + const dispatch = useDispatch(); + const metadata = useFlagshipMetadataStatus(); + const policyengineVersion = useSelector(selectV2CatalogVersion); + const parameters = useSelector((state: RootState) => state.metadata.parameters); + const missingNames = useSelector((state: RootState) => state.metadata.v2MissingParameterNames); + const toLoad = useMemo( + () => namesToLoad(parameters, missingNames, names), + [names, parameters, missingNames] + ); + const query = useQuery({ + queryKey: catalogParametersKey(countryId, policyengineVersion, toLoad), + queryFn: () => loadIntoStore(dispatch, countryId, policyengineVersion!, toLoad), + enabled: policyengineVersion !== null && toLoad.length > 0, + staleTime: Infinity, + gcTime: PARAMETER_QUERY_GC_TIME, + }); + + if (toLoad.length === 0) { + return { status: 'ready', message: null, retry: metadata.retry }; + } + if (metadata.status === 'error') { + return { status: 'error', message: metadata.message, retry: metadata.retry }; + } + if (query.isError) { + return { + status: 'error', + message: query.error instanceof Error ? query.error.message : null, + retry: query.refetch, + }; + } + return { status: 'loading', message: null, retry: query.refetch }; +} + +/** + * Whether the named parameters, with their values, are in the metadata + * store for the routes v3 uses (V3_API_ROUTES, a constant, so every render + * calls the same hooks); on the v2 routes, loads those that are not. + */ +export const useEnsureV3Parameters: (names: readonly string[]) => V3ParametersStatus = + V3_API_ROUTES === 'v2' ? useV2EnsureParameters : useV1EnsureParameters; + +function useV1LoadParameters(): (names: readonly string[]) => Promise { + return useCallback(async () => {}, []); +} + +function useV2LoadParameters(): (names: readonly string[]) => Promise { + const countryId = useCurrentCountry(); + const dispatch = useDispatch(); + const store = useStore(); + const queryClient = useQueryClient(); + return useCallback( + async (names: readonly string[]) => { + const state = store.getState(); + const policyengineVersion = selectV2CatalogVersion(state); + const toLoad = namesToLoad( + state.metadata.parameters, + state.metadata.v2MissingParameterNames, + names + ); + if (policyengineVersion === null || toLoad.length === 0) { + return; + } + await queryClient.fetchQuery({ + queryKey: catalogParametersKey(countryId, policyengineVersion, toLoad), + queryFn: () => loadIntoStore(dispatch, countryId, policyengineVersion, toLoad), + staleTime: Infinity, + gcTime: PARAMETER_QUERY_GC_TIME, + }); + }, + [countryId, dispatch, store, queryClient] + ); +} + +/** + * A function that loads the named parameters into the store and resolves + * once they are in (at once on the v1 routes, where they always are), for + * actions such as adding a parameter to a draft. + */ +export const useLoadV3Parameters: () => (names: readonly string[]) => Promise = + V3_API_ROUTES === 'v2' ? useV2LoadParameters : useV1LoadParameters; diff --git a/app/src/reducers/metadataReducer.ts b/app/src/reducers/metadataReducer.ts index 589225c2d..c25b13360 100644 --- a/app/src/reducers/metadataReducer.ts +++ b/app/src/reducers/metadataReducer.ts @@ -4,6 +4,7 @@ import { fetchMetadata as fetchMetadataApi } from '@/api/metadata'; import { fetchV2ModelCatalogMetadata } from '@/api/publicApi/modelCatalog'; import { buildParameterTree } from '@/libs/buildParameterTree'; import { MetadataRoutes, MetadataState } from '@/types/metadata'; +import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; /** * What to load: a country alone reads `GET /{country}/metadata` (v1), as @@ -96,6 +97,38 @@ const metadataSlice = createSlice({ clearMetadata(state) { return { ...initialState, currentCountry: state.currentCountry }; }, + /** + * Parameters loaded on demand from the v2 routes join the store, for + * the catalog version they came from only: a response for an earlier + * country or version is dropped. + */ + catalogParametersLoaded( + state, + action: PayloadAction<{ + countryId: string; + policyengineVersion: string; + parameters: ParameterMetadataCollection; + parameterIds: Record; + missingNames: string[]; + }> + ) { + const { countryId, policyengineVersion, parameters, parameterIds, missingNames } = + action.payload; + if ( + state.routes !== 'v2' || + state.currentCountry !== countryId || + state.v2Catalog?.policyengineVersion !== policyengineVersion + ) { + return; + } + Object.assign(state.parameters, parameters); + state.v2ParameterIds = { ...state.v2ParameterIds, ...parameterIds }; + if (missingNames.length > 0) { + state.v2MissingParameterNames = [ + ...new Set([...(state.v2MissingParameterNames ?? []), ...missingNames]), + ]; + } + }, }, extraReducers: (builder) => { builder @@ -168,7 +201,7 @@ const metadataSlice = createSlice({ }, }); -export const { setCurrentCountry, clearMetadata } = metadataSlice.actions; +export const { setCurrentCountry, clearMetadata, catalogParametersLoaded } = metadataSlice.actions; // currentCountry identifies both the active request and the metadata used by readiness checks. diff --git a/app/src/tests/fixtures/api/publicApi/catalogParametersMocks.ts b/app/src/tests/fixtures/api/publicApi/catalogParametersMocks.ts new file mode 100644 index 000000000..cdddc18db --- /dev/null +++ b/app/src/tests/fixtures/api/publicApi/catalogParametersMocks.ts @@ -0,0 +1,64 @@ +import type { components } from '@/api/publicApi/schema'; +import { TEST_POLICYENGINE_VERSION } from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; +import { + STANDARD_DEDUCTION_JOINT, + STANDARD_DEDUCTION_SINGLE, +} from '@/tests/fixtures/api/publicApi/searchFileMocks'; + +type Schemas = components['schemas']; + +export const STANDARD_DEDUCTION_SINGLE_ID = '1f3e5a7b-9c11-4d2e-8f4a-6b8c0d2e4f61'; +export const STANDARD_DEDUCTION_JOINT_ID = '2a4c6e80-1b3d-4f5a-9c7e-8d0f2a4c6e72'; +export const UNKNOWN_PARAMETER = 'gov.irs.not_in_this_version'; + +/** Current law for the single filer's standard deduction, as the catalog dates it. */ +export const STANDARD_DEDUCTION_SINGLE_PARAMETER: Schemas['MetadataCatalogParameter'] = { + id: STANDARD_DEDUCTION_SINGLE_ID, + name: STANDARD_DEDUCTION_SINGLE, + label: null, + description: 'Standard deduction for single filers.', + data_type: 'float', + unit: 'currency-USD', + economy: true, + household: true, + values: [ + { start_date: '0001-01-01T00:00:00', end_date: null, value: 12000 }, + { start_date: '2025-01-01T00:00:00', end_date: null, value: 15000 }, + ], +}; + +export const STANDARD_DEDUCTION_JOINT_PARAMETER: Schemas['MetadataCatalogParameter'] = { + id: STANDARD_DEDUCTION_JOINT_ID, + name: STANDARD_DEDUCTION_JOINT, + label: 'Standard deduction (joint)', + description: null, + data_type: 'float', + unit: 'currency-USD', + economy: true, + household: false, + values: [{ start_date: '2025-01-01T00:00:00', end_date: null, value: 30000 }], +}; + +const CATALOG = [STANDARD_DEDUCTION_SINGLE_PARAMETER, STANDARD_DEDUCTION_JOINT_PARAMETER]; + +const NODE_LABELS: Record = { + gov: 'Government', + 'gov.irs': 'IRS', + 'gov.irs.deductions': null, + 'gov.irs.deductions.standard': 'Standard deduction', + 'gov.irs.deductions.standard.amount': 'Standard deduction amount', +}; + +/** What `GET /v2/model-catalog/parameters` answers for the names in `url`. */ +export function catalogParametersResult( + names: string[] +): Schemas['MetadataCatalogParametersResult'] { + const found = CATALOG.filter((parameter) => names.includes(parameter.name)); + return { + policyengine_version: TEST_POLICYENGINE_VERSION, + catalog_format: 2, + parameters: found, + node_labels: found.length > 0 ? NODE_LABELS : {}, + missing_names: names.filter((name) => !found.some((parameter) => parameter.name === name)), + }; +} diff --git a/app/src/tests/unit/api/publicApi/catalogParameters.test.ts b/app/src/tests/unit/api/publicApi/catalogParameters.test.ts new file mode 100644 index 000000000..a1f2a4313 --- /dev/null +++ b/app/src/tests/unit/api/publicApi/catalogParameters.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test, vi } from 'vitest'; +import { CATALOG_PARAMETER_BATCH, fetchCatalogParameters } from '@/api/publicApi/catalogParameters'; +import { createPublicApiClient } from '@/api/publicApi/client'; +import { + catalogParametersResult, + STANDARD_DEDUCTION_JOINT_ID, + STANDARD_DEDUCTION_SINGLE_ID, + UNKNOWN_PARAMETER, +} from '@/tests/fixtures/api/publicApi/catalogParametersMocks'; +import { TEST_POLICYENGINE_VERSION } from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; +import { jsonResponse } from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { + STANDARD_DEDUCTION_JOINT, + STANDARD_DEDUCTION_SINGLE, +} from '@/tests/fixtures/api/publicApi/searchFileMocks'; +import { TEST_API_BASE } from '@/tests/fixtures/api/v2UserMocks'; + +function catalogClient() { + const fetchMock = vi.fn(async (request: Request) => { + const names = new URL(request.url).searchParams.getAll('name'); + return jsonResponse({ status: 'ok', message: null, result: catalogParametersResult(names) }); + }); + const client = createPublicApiClient({ + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + return { client, fetchMock }; +} + +describe('fetchCatalogParameters', () => { + test('given names then one request asks for all of them at the catalog version', async () => { + // Given + const { client, fetchMock } = catalogClient(); + + // When + await fetchCatalogParameters( + 'us', + TEST_POLICYENGINE_VERSION, + [STANDARD_DEDUCTION_SINGLE, STANDARD_DEDUCTION_JOINT], + client + ); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = new URL((fetchMock.mock.calls[0][0] as Request).url); + expect(url.pathname).toBe('/v2/model-catalog/parameters'); + expect(url.searchParams.get('policyengine_version')).toBe(TEST_POLICYENGINE_VERSION); + expect(url.searchParams.getAll('name').sort()).toEqual( + [STANDARD_DEDUCTION_JOINT, STANDARD_DEDUCTION_SINGLE].sort() + ); + }); + + test('given more names than one request takes then they are split into batches', async () => { + const { client, fetchMock } = catalogClient(); + const names = Array.from( + { length: CATALOG_PARAMETER_BATCH + 1 }, + (_, index) => `gov.example.parameter_${index}` + ); + + await fetchCatalogParameters('us', TEST_POLICYENGINE_VERSION, names, client); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('given a parameter then its values are dated as v1 dated them', async () => { + const { client } = catalogClient(); + + const loaded = await fetchCatalogParameters( + 'us', + TEST_POLICYENGINE_VERSION, + [STANDARD_DEDUCTION_SINGLE], + client + ); + + expect(loaded.parameters[STANDARD_DEDUCTION_SINGLE]).toMatchObject({ + type: 'parameter', + parameter: STANDARD_DEDUCTION_SINGLE, + label: 'SINGLE', + unit: 'currency-USD', + economy: true, + household: true, + values: { '0000-01-01': 12000, '2025-01-01': 15000 }, + }); + }); + + test('given the ancestors labels then breadcrumbs read them, with v1 labels for the unlabelled', async () => { + const { client } = catalogClient(); + + const loaded = await fetchCatalogParameters( + 'us', + TEST_POLICYENGINE_VERSION, + [STANDARD_DEDUCTION_SINGLE], + client + ); + + expect(loaded.parameters['gov.irs.deductions.standard']).toMatchObject({ + type: 'parameterNode', + label: 'Standard deduction', + }); + expect(loaded.parameters['gov.irs.deductions'].label).toBe('deductions'); + }); + + test('given the result then catalog IDs and missing names are kept', async () => { + const { client } = catalogClient(); + + const loaded = await fetchCatalogParameters( + 'us', + TEST_POLICYENGINE_VERSION, + [STANDARD_DEDUCTION_SINGLE, STANDARD_DEDUCTION_JOINT, UNKNOWN_PARAMETER], + client + ); + + expect(loaded.parameterIds).toEqual({ + [STANDARD_DEDUCTION_SINGLE]: STANDARD_DEDUCTION_SINGLE_ID, + [STANDARD_DEDUCTION_JOINT]: STANDARD_DEDUCTION_JOINT_ID, + }); + expect(loaded.missingNames).toEqual([UNKNOWN_PARAMETER]); + }); +}); diff --git a/app/src/tests/unit/hooks/useV3ParametersV1.test.tsx b/app/src/tests/unit/hooks/useV3ParametersV1.test.tsx new file mode 100644 index 000000000..5a874c268 --- /dev/null +++ b/app/src/tests/unit/hooks/useV3ParametersV1.test.tsx @@ -0,0 +1,40 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { useEnsureV3Parameters, useLoadV3Parameters } from '@/hooks/useV3Parameters'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { stubPublicApiFetch } from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { v3Wrapper } from '@/tests/fixtures/hooks/v3MetadataWrapper'; + +describe('useEnsureV3Parameters on the v1 routes (before the switch)', () => { + beforeEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + test('given the whole metadata loaded then every parameter is ready, with no request', () => { + // Given + const fetchStub = stubPublicApiFetch(); + seedDeductionMetadata(); + + // When + const { result } = renderHook(() => useEnsureV3Parameters(['gov.irs.anything']), { + wrapper: v3Wrapper(), + }); + + // Then + expect(result.current.status).toBe('ready'); + expect(fetchStub).not.toHaveBeenCalled(); + }); + + test('given the load function then it resolves without a request', async () => { + const fetchStub = stubPublicApiFetch(); + seedDeductionMetadata(); + const { result } = renderHook(() => useLoadV3Parameters(), { wrapper: v3Wrapper() }); + + await result.current(['gov.irs.anything']); + + expect(fetchStub).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/tests/unit/hooks/useV3ParametersV2.test.tsx b/app/src/tests/unit/hooks/useV3ParametersV2.test.tsx new file mode 100644 index 000000000..2bb7742a9 --- /dev/null +++ b/app/src/tests/unit/hooks/useV3ParametersV2.test.tsx @@ -0,0 +1,160 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { useEnsureV3Parameters, useLoadV3Parameters } from '@/hooks/useV3Parameters'; +import { store } from '@/store'; +import { + catalogParametersResult, + STANDARD_DEDUCTION_SINGLE_ID, + UNKNOWN_PARAMETER, +} from '@/tests/fixtures/api/publicApi/catalogParametersMocks'; +import { + jsonResponse, + requestsTo, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { + STANDARD_DEDUCTION_JOINT, + STANDARD_DEDUCTION_SINGLE, +} from '@/tests/fixtures/api/publicApi/searchFileMocks'; +import { seedV2ModelCatalog, v3Wrapper } from '@/tests/fixtures/hooks/v3MetadataWrapper'; + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +const PARAMETERS_PATH = '/v2/model-catalog/parameters'; + +function answerCatalogParameters(url: URL) { + return jsonResponse({ + status: 'ok', + message: null, + result: catalogParametersResult(url.searchParams.getAll('name')), + }); +} + +describe('useEnsureV3Parameters with v3 on the v2 routes', () => { + let fetchStub: ReturnType; + + beforeEach(async () => { + fetchStub = stubPublicApiFetch({ [PARAMETERS_PATH]: answerCatalogParameters }); + await seedV2ModelCatalog(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('given parameters not yet loaded then they load in one request, values and all', async () => { + // When + const { result } = renderHook( + () => useEnsureV3Parameters([STANDARD_DEDUCTION_SINGLE, STANDARD_DEDUCTION_JOINT]), + { wrapper: v3Wrapper() } + ); + + // Then + expect(result.current.status).toBe('loading'); + await waitFor(() => expect(result.current.status).toBe('ready')); + expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(1); + const { parameters, v2ParameterIds } = store.getState().metadata; + expect(parameters[STANDARD_DEDUCTION_SINGLE].values).toEqual({ + '0000-01-01': 12000, + '2025-01-01': 15000, + }); + expect(parameters['gov.irs.deductions.standard'].label).toBe('Standard deduction'); + expect(v2ParameterIds?.[STANDARD_DEDUCTION_SINGLE]).toBe(STANDARD_DEDUCTION_SINGLE_ID); + }); + + test('given parameters already loaded then nothing is asked for again', async () => { + // Given + const first = renderHook(() => useEnsureV3Parameters([STANDARD_DEDUCTION_SINGLE]), { + wrapper: v3Wrapper(), + }); + await waitFor(() => expect(first.result.current.status).toBe('ready')); + + // When + const { result } = renderHook(() => useEnsureV3Parameters([STANDARD_DEDUCTION_SINGLE]), { + wrapper: v3Wrapper(), + }); + + // Then + expect(result.current.status).toBe('ready'); + expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(1); + }); + + test('given a name the catalog version does not have then it counts as loaded, once', async () => { + const { result, rerender } = renderHook(() => useEnsureV3Parameters([UNKNOWN_PARAMETER]), { + wrapper: v3Wrapper(), + }); + + await waitFor(() => expect(result.current.status).toBe('ready')); + rerender(); + expect(store.getState().metadata.v2MissingParameterNames).toContain(UNKNOWN_PARAMETER); + expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(1); + }); + + test('given no names then it is ready at once', () => { + const { result } = renderHook(() => useEnsureV3Parameters([]), { wrapper: v3Wrapper() }); + + expect(result.current.status).toBe('ready'); + expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(0); + }); + + test('given a failed request then the status says so and retry asks again', async () => { + // Given + let attempts = 0; + fetchStub = stubPublicApiFetch({ + [PARAMETERS_PATH]: (url) => { + attempts += 1; + return attempts === 1 + ? jsonResponse({ status: 'error', message: 'Unavailable' }, 503) + : answerCatalogParameters(url); + }, + }); + const { result } = renderHook(() => useEnsureV3Parameters([STANDARD_DEDUCTION_SINGLE]), { + wrapper: v3Wrapper(), + }); + await waitFor(() => expect(result.current.status).toBe('error')); + + // When + act(() => { + result.current.retry(); + }); + + // Then + await waitFor(() => expect(result.current.status).toBe('ready')); + }); +}); + +describe('useLoadV3Parameters with v3 on the v2 routes', () => { + let fetchStub: ReturnType; + + beforeEach(async () => { + fetchStub = stubPublicApiFetch({ [PARAMETERS_PATH]: answerCatalogParameters }); + await seedV2ModelCatalog(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('given names then the returned load resolves once they are in the store', async () => { + // Given + const { result } = renderHook(() => useLoadV3Parameters(), { wrapper: v3Wrapper() }); + + // When + await act(async () => { + await result.current([STANDARD_DEDUCTION_SINGLE]); + }); + + // Then + expect(store.getState().metadata.parameters[STANDARD_DEDUCTION_SINGLE]?.values).toBeDefined(); + + // And loading them again asks for nothing + await act(async () => { + await result.current([STANDARD_DEDUCTION_SINGLE]); + }); + expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(1); + }); +}); diff --git a/app/src/tests/unit/reducers/metadataReducerV2.test.ts b/app/src/tests/unit/reducers/metadataReducerV2.test.ts index 521f0ad45..91eb27aba 100644 --- a/app/src/tests/unit/reducers/metadataReducerV2.test.ts +++ b/app/src/tests/unit/reducers/metadataReducerV2.test.ts @@ -1,6 +1,10 @@ import { configureStore } from '@reduxjs/toolkit'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import metadataReducer, { fetchMetadataThunk, metadataRequest } from '@/reducers/metadataReducer'; +import metadataReducer, { + catalogParametersLoaded, + fetchMetadataThunk, + metadataRequest, +} from '@/reducers/metadataReducer'; import { TEST_POLICYENGINE_VERSION, TEST_TAX_BENEFIT_MODEL_ID, @@ -110,4 +114,55 @@ describe('metadataReducer with the v2 routes', () => { expect(store.getState().metadata.error).toBe('The catalog is not published'); expect(store.getState().metadata.loading).toBe(false); }); + + test('given parameters loaded for the current catalog version then they join the store', async () => { + // Given + mockFetchV2ModelCatalogMetadata.mockResolvedValue(v2Payload()); + const store = testStore(); + await store.dispatch(fetchMetadataThunk(metadataRequest('us', 'v2'))); + + // When + store.dispatch( + catalogParametersLoaded({ + countryId: 'us', + policyengineVersion: TEST_POLICYENGINE_VERSION, + parameters: { + 'gov.example.rate': { + type: 'parameter', + parameter: 'gov.example.rate', + label: 'Rate', + values: { '2025-01-01': 0.1 }, + }, + }, + parameterIds: { 'gov.example.rate': 'parameter-id' }, + missingNames: ['gov.example.missing'], + }) + ); + + // Then + const state = store.getState().metadata; + expect(state.parameters['gov.example.rate'].values).toEqual({ '2025-01-01': 0.1 }); + expect(state.v2ParameterIds).toEqual({ 'gov.example.rate': 'parameter-id' }); + expect(state.v2MissingParameterNames).toEqual(['gov.example.missing']); + }); + + test('given parameters for another catalog version then they are dropped', async () => { + mockFetchV2ModelCatalogMetadata.mockResolvedValue(v2Payload()); + const store = testStore(); + await store.dispatch(fetchMetadataThunk(metadataRequest('us', 'v2'))); + + store.dispatch( + catalogParametersLoaded({ + countryId: 'us', + policyengineVersion: '0.0.1', + parameters: { + 'gov.example.rate': { type: 'parameter', parameter: 'gov.example.rate', label: 'Rate' }, + }, + parameterIds: {}, + missingNames: [], + }) + ); + + expect(store.getState().metadata.parameters).toEqual({}); + }); }); diff --git a/app/src/types/metadata.ts b/app/src/types/metadata.ts index 1481c223f..20ff4722a 100644 --- a/app/src/types/metadata.ts +++ b/app/src/types/metadata.ts @@ -109,6 +109,13 @@ export interface MetadataState { routes?: MetadataRoutes; /** The v2 catalog version, when the metadata came from the v2 routes. */ v2Catalog?: V2CatalogReference | null; + /** + * On the v2 routes, `parameters` holds only what has been loaded on + * demand; these are the catalog IDs of those parameters (which + * `/v2/policies` takes) and the names the catalog version does not have. + */ + v2ParameterIds?: Record; + v2MissingParameterNames?: string[]; // Computed parameter tree for policy creation UI (built when metadata is fetched) parameterTree: ParameterTreeNode | null; From 7e96953ddebc6672ae55de9bfab54d8bc62519e8 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:35:18 +0400 Subject: [PATCH 05/28] Have v3's views name the parameters they show and wait for them On the v2 routes the metadata store holds only the parameters loaded on demand, so each v3 view that reads current law or parameter labels now names what it shows and waits for it, with the loading or error state it already has. On the v1 routes the same hooks report the metadata's own status, so what users see is unchanged. - Build: waits for the search file and for the draft's parameters (its provisions, their groups' members once the search file names them, and the indexes a value can grow with); a picked parameter's values load before it joins the draft, and a failure says so. The add bar, the draft table and the provision details read what a draft can take from `useV3SearchCatalog`, and the comparison picker waits for the compared reform's parameters. - Ask: matches from the search file, says it is loading until the file is in, loads a match's current law before adding it, and loads a chat reform's parameters before building its draft. - Your reforms, a report and a bill's report load the parameters of the reform, policy or bill they show in one request, and retry them. - The v3 page layout starts the search file's download behind the page. Co-Authored-By: Claude Opus 5.5 --- .../components/flagship/WorkspaceLayout.tsx | 4 + .../flagship/reform/AddParameterBar.tsx | 29 ++- .../flagship/reform/BaselinePicker.tsx | 15 +- .../components/flagship/reform/DraftTable.tsx | 5 +- .../flagship/reform/ParametersStatus.tsx | 23 ++- .../flagship/reform/ProvisionDetails.tsx | 4 +- app/src/hooks/useV3Parameters.ts | 59 ++++-- app/src/libs/flagship/uprating.ts | 5 + app/src/libs/flagship/v3ParameterNames.ts | 62 +++++++ app/src/pages/flagship/Ask.page.tsx | 131 +++++++++---- app/src/pages/flagship/BillReport.page.tsx | 44 ++++- app/src/pages/flagship/Build.page.tsx | 66 +++++-- app/src/pages/flagship/Reforms.page.tsx | 15 +- app/src/pages/flagship/Report.page.tsx | 26 ++- .../api/publicApi/publicApiFetchStub.ts | 13 +- .../flagship/WorkspaceLayoutV2.test.tsx | 54 ++++++ .../unit/hooks/useV3ParametersV1.test.tsx | 5 +- .../unit/hooks/useV3ParametersV2.test.tsx | 6 +- .../libs/flagship/v3ParameterNames.test.ts | 67 +++++++ .../tests/unit/pages/flagship/AskV2.test.tsx | 103 +++++++++++ .../unit/pages/flagship/BillReportV2.test.tsx | 167 +++++++++++++++++ .../unit/pages/flagship/BuildV2.test.tsx | 139 ++++++++++++++ .../unit/pages/flagship/ReformsV2.test.tsx | 117 ++++++++++++ .../unit/pages/flagship/ReportV2.test.tsx | 172 ++++++++++++++++++ 24 files changed, 1211 insertions(+), 120 deletions(-) create mode 100644 app/src/libs/flagship/v3ParameterNames.ts create mode 100644 app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx create mode 100644 app/src/tests/unit/libs/flagship/v3ParameterNames.test.ts create mode 100644 app/src/tests/unit/pages/flagship/AskV2.test.tsx create mode 100644 app/src/tests/unit/pages/flagship/BillReportV2.test.tsx create mode 100644 app/src/tests/unit/pages/flagship/BuildV2.test.tsx create mode 100644 app/src/tests/unit/pages/flagship/ReformsV2.test.tsx create mode 100644 app/src/tests/unit/pages/flagship/ReportV2.test.tsx diff --git a/app/src/components/flagship/WorkspaceLayout.tsx b/app/src/components/flagship/WorkspaceLayout.tsx index 852886afe..82818b94e 100644 --- a/app/src/components/flagship/WorkspaceLayout.tsx +++ b/app/src/components/flagship/WorkspaceLayout.tsx @@ -1,4 +1,5 @@ import { spacing } from '@/designTokens'; +import { useV3SearchFileInBackground } from '@/hooks/useV3SearchCatalog'; interface WorkspaceLayoutProps { children: React.ReactNode; @@ -13,6 +14,9 @@ interface WorkspaceLayoutProps { */ export default function WorkspaceLayout({ children, wide = false }: WorkspaceLayoutProps) { const contentWidth = wide ? 1400 : 760; + // On the v2 routes, the search file starts downloading once the page has + // rendered, so it is in by the time someone searches (nothing on v1). + useV3SearchFileInBackground(); return
{children}
; } diff --git a/app/src/components/flagship/reform/AddParameterBar.tsx b/app/src/components/flagship/reform/AddParameterBar.tsx index b2f3b1727..33b743d6b 100644 --- a/app/src/components/flagship/reform/AddParameterBar.tsx +++ b/app/src/components/flagship/reform/AddParameterBar.tsx @@ -7,18 +7,11 @@ import ParametersStatus from '@/components/flagship/reform/ParametersStatus'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useV3SearchCatalog } from '@/hooks/useV3SearchCatalog'; import { useDraftReform } from '@/libs/draftReform'; import { PHONE_QUERY } from '@/libs/flagship/viewport'; import { getStateLabels } from '@/libs/metadataUtils'; -import { - ParameterSearchEntry, - properNodeLabel, - selectAddableParameterPaths, - selectConceptClusters, - selectParameterEntriesByPath, - selectParameterSearchEntries, - selectParameterSearchIndex, -} from '@/libs/parameterSearch'; +import { ParameterSearchEntry, properNodeLabel } from '@/libs/parameterSearch'; import { RootState } from '@/store'; import { trackParameterSearchSelected } from '@/utils/analytics'; import { formatValue, getCurrentValue } from '@/utils/parameterValues'; @@ -61,15 +54,15 @@ export default function AddParameterBar({ inTable = false, }: AddParameterBarProps) { const countryId = useCurrentCountry(); - const entries = useSelector(selectParameterSearchEntries); - const clusters = useSelector(selectConceptClusters); + // From the whole metadata on the v1 routes, the search file on v2; + // memoized outside the component, so mounts don't rebuild the index. + const catalog = useV3SearchCatalog(); + const { entries, clusters, entriesByPath, addablePaths } = catalog; + const searchIndex = catalog.index; const stateLabels = useSelector(getStateLabels); - // Store-memoized: survives navigation, so mounts don't rebuild it. - const searchIndex = useSelector(selectParameterSearchIndex); + // Current-law values, for the parameters loaded so far on the v2 routes. const parameters = useSelector((state: RootState) => state.metadata.parameters); const parameterTree = useSelector((state: RootState) => state.metadata.parameterTree); - const entriesByPath = useSelector(selectParameterEntriesByPath); - const addablePaths = useSelector(selectAddableParameterPaths); const draft = useDraftReform(); const draftPaths = new Set( draft?.countryId === countryId ? draft.provisions.map((p) => p.path) : [] @@ -142,8 +135,8 @@ export default function AddParameterBar({ }, [inTable, entries.length]); // Until the index is in, the bar's place holds its loading (or failed) state. - if (entries.length === 0) { - return ; + if (catalog.status !== 'ready') { + return ; } return ( @@ -174,7 +167,7 @@ export default function AddParameterBar({ pick(entry); }} labelFor={(path) => { - const label = parameters?.[path]?.label; + const label = catalog.labelFor(path); return label ? properNodeLabel(path, label) : null; }} backgroundSearch diff --git a/app/src/components/flagship/reform/BaselinePicker.tsx b/app/src/components/flagship/reform/BaselinePicker.tsx index 2f3817ee1..fe670dd21 100644 --- a/app/src/components/flagship/reform/BaselinePicker.tsx +++ b/app/src/components/flagship/reform/BaselinePicker.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { IconCheck, IconChevronDown, IconScale } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; @@ -16,9 +16,11 @@ import { } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { useReformStore, useV3SavedWork } from '@/hooks/useFlagshipStores'; +import { useEnsureV3Parameters } from '@/hooks/useV3Parameters'; import { provisionAdjusts, provisionIntervals, type DraftReform } from '@/libs/draftReform'; import { sameReformParameters } from '@/libs/flagship/reformMatch'; import type { RunReportBaseline } from '@/libs/flagship/runReport'; +import { reformParameterNames } from '@/libs/flagship/v3ParameterNames'; import { RootState } from '@/store'; import type { Reform } from '@/types/ingredients/Reform'; @@ -51,6 +53,10 @@ export function useDraftBaseline(draft: DraftReform) { const reform = draft.baselineReformId ? options.find((option) => option.id === draft.baselineReformId) : undefined; + // Current law for the comparison's parameters: on the v2 routes they + // load once it is chosen (the draft's own load with the draft). + const baselineNames = useMemo(() => (reform ? reformParameterNames([reform]) : []), [reform]); + const baselineParameters = useEnsureV3Parameters(baselineNames); const forRun: RunReportBaseline | undefined = reform && { reformId: reform.id, label: savedReformName(reform), @@ -71,8 +77,11 @@ export function useDraftBaseline(draft: DraftReform) { options, reform, forRun, - /** The chosen comparison is not known until the saved reforms load. */ - pending: !!draft.baselineReformId && isPending, + /** + * The chosen comparison is not known until the saved reforms load, nor + * what it changes until current law for its parameters is in. + */ + pending: (!!draft.baselineReformId && isPending) || (!!reform && baselineParameters.waiting), /** The draft changes just what its comparison changes: the report would show no change. */ matchesDraft: !!reform && draftChanges.length > 0 && sameReformParameters(baselineChanges, draftChanges), diff --git a/app/src/components/flagship/reform/DraftTable.tsx b/app/src/components/flagship/reform/DraftTable.tsx index ab33eb847..7cc165ed4 100644 --- a/app/src/components/flagship/reform/DraftTable.tsx +++ b/app/src/components/flagship/reform/DraftTable.tsx @@ -2,6 +2,7 @@ import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from import { IconChevronDown, IconX } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; import { colors, spacing, typography } from '@/designTokens'; +import { useV3SearchCatalog } from '@/hooks/useV3SearchCatalog'; import { currentLawForYear, DraftProvision, @@ -20,7 +21,6 @@ import { provisionName, summarizeIntervals } from '@/libs/flagship/draftLabels'; import { ParameterGroup, parameterGroup } from '@/libs/flagship/parameterGroups'; import { growthOf, growthPhrase, setValueFromYear } from '@/libs/flagship/uprating'; import { getDateRange } from '@/libs/metadataUtils'; -import { selectAddableParameterPaths } from '@/libs/parameterSearch'; import { RootState } from '@/store'; import { formatCurrentLaw, formatValue } from '@/utils/parameterValues'; import ProvisionDetails from './ProvisionDetails'; @@ -170,7 +170,8 @@ export default function DraftTable({ addRow?: React.ReactNode; }) { const parameters = useSelector((state: RootState) => state.metadata.parameters); - const draftable = useSelector(selectAddableParameterPaths); + // The parameters a draft can take: their groups show as one table row. + const draftable = useV3SearchCatalog().addablePaths; const lastYear = Number(useSelector(getDateRange).maxDate.slice(0, 4)); const focus = useProvisionFocus(); // One row open at a time: the one being worked on. diff --git a/app/src/components/flagship/reform/ParametersStatus.tsx b/app/src/components/flagship/reform/ParametersStatus.tsx index 1c6a30f01..64846a5cc 100644 --- a/app/src/components/flagship/reform/ParametersStatus.tsx +++ b/app/src/components/flagship/reform/ParametersStatus.tsx @@ -109,21 +109,36 @@ function DraftTableSkeleton({ rows }: { rows: number }) { ); } +/** What Build waits on: its loading state, and how to ask again after a failure. */ +export interface ParametersLoader { + status: 'loading' | 'error' | 'ready'; + retry: () => unknown; +} + /** * Build waits on the model's parameters: the add bar has nothing to * search and the table cannot group breakdowns until they arrive. While * they load, placeholders hold the bar's place (and the table's, for a * draft of `paths`, a row per group as the table draws them); if they - * fail, the error says so and asks for them again. + * fail, the error says so and asks for them again. `loader` names what is + * awaited (on the v2 routes, the search file and the draft's parameters); + * without one, the model's metadata. */ -export default function ParametersStatus({ paths = [] }: { paths?: string[] }) { +export default function ParametersStatus({ + paths = [], + loader, +}: { + paths?: string[]; + loader?: ParametersLoader; +}) { const metadata = useFlagshipMetadataStatus(); + const awaited = loader ?? metadata; const rows = new Set(paths.map(groupKeyFromPath)).size; - if (metadata.status === 'error') { + if (awaited.status === 'error') { return (
- +
); } diff --git a/app/src/components/flagship/reform/ProvisionDetails.tsx b/app/src/components/flagship/reform/ProvisionDetails.tsx index b2b5d9a7a..537d4f4fd 100644 --- a/app/src/components/flagship/reform/ProvisionDetails.tsx +++ b/app/src/components/flagship/reform/ProvisionDetails.tsx @@ -12,6 +12,7 @@ import { } from '@/components/ui'; import { FOREVER } from '@/constants'; import { colors, spacing, typography } from '@/designTokens'; +import { useV3SearchCatalog } from '@/hooks/useV3SearchCatalog'; import { currentLawForYear, DraftProvision, @@ -41,7 +42,6 @@ import { setValueFromYear, } from '@/libs/flagship/uprating'; import { getDateRange, getTaxYears } from '@/libs/metadataUtils'; -import { selectParameterEntriesByPath } from '@/libs/parameterSearch'; import { ChangesCard } from '@/pages/reportBuilder/modals/policyCreation/ChangesCard'; import { RootState } from '@/store'; import { ValueInterval, ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; @@ -99,7 +99,7 @@ function fitsGrid(provision: DraftProvision | undefined): boolean { */ export default function ProvisionDetails({ draft, path, group, focusPath }: ProvisionDetailsProps) { const parameters = useSelector((state: RootState) => state.metadata.parameters); - const entriesByPath = useSelector(selectParameterEntriesByPath); + const entriesByPath = useV3SearchCatalog().entriesByPath; const { minDate, maxDate } = useSelector(getDateRange); const taxYears = useSelector(getTaxYears); const year = draftYear(draft); diff --git a/app/src/hooks/useV3Parameters.ts b/app/src/hooks/useV3Parameters.ts index 3b0b2b5fa..d7aa087fb 100644 --- a/app/src/hooks/useV3Parameters.ts +++ b/app/src/hooks/useV3Parameters.ts @@ -21,6 +21,12 @@ import type { AppDispatch, RootState } from '@/store'; */ export interface V3ParametersStatus { status: 'loading' | 'error' | 'ready'; + /** + * Whether the names are being loaded on demand right now: only ever on + * the v2 routes. On v1, 'loading' means the whole metadata is not in + * yet, which views that never waited for it before can ignore. + */ + waiting: boolean; /** The failure's message, when the status is 'error'. */ message: string | null; retry: () => unknown; @@ -68,7 +74,12 @@ async function loadIntoStore( function useV1EnsureParameters(_names: readonly string[]): V3ParametersStatus { const metadata = useFlagshipMetadataStatus(); - return { status: metadata.status, message: metadata.message, retry: metadata.retry }; + return { + status: metadata.status, + waiting: false, + message: metadata.message, + retry: metadata.retry, + }; } function useV2EnsureParameters(names: readonly string[]): V3ParametersStatus { @@ -91,19 +102,20 @@ function useV2EnsureParameters(names: readonly string[]): V3ParametersStatus { }); if (toLoad.length === 0) { - return { status: 'ready', message: null, retry: metadata.retry }; + return { status: 'ready', waiting: false, message: null, retry: metadata.retry }; } if (metadata.status === 'error') { - return { status: 'error', message: metadata.message, retry: metadata.retry }; + return { status: 'error', waiting: false, message: metadata.message, retry: metadata.retry }; } if (query.isError) { return { status: 'error', + waiting: false, message: query.error instanceof Error ? query.error.message : null, retry: query.refetch, }; } - return { status: 'loading', message: null, retry: query.refetch }; + return { status: 'loading', waiting: true, message: null, retry: query.refetch }; } /** @@ -114,17 +126,20 @@ function useV2EnsureParameters(names: readonly string[]): V3ParametersStatus { export const useEnsureV3Parameters: (names: readonly string[]) => V3ParametersStatus = V3_API_ROUTES === 'v2' ? useV2EnsureParameters : useV1EnsureParameters; -function useV1LoadParameters(): (names: readonly string[]) => Promise { - return useCallback(async () => {}, []); +/** Loads names, or answers null when they are all in already (so callers can act at once). */ +export type LoadV3Parameters = (names: readonly string[]) => Promise | null; + +function useV1LoadParameters(): LoadV3Parameters { + return useCallback(() => null, []); } -function useV2LoadParameters(): (names: readonly string[]) => Promise { +function useV2LoadParameters(): LoadV3Parameters { const countryId = useCurrentCountry(); const dispatch = useDispatch(); const store = useStore(); const queryClient = useQueryClient(); return useCallback( - async (names: readonly string[]) => { + (names: readonly string[]) => { const state = store.getState(); const policyengineVersion = selectV2CatalogVersion(state); const toLoad = namesToLoad( @@ -132,15 +147,20 @@ function useV2LoadParameters(): (names: readonly string[]) => Promise { state.metadata.v2MissingParameterNames, names ); - if (policyengineVersion === null || toLoad.length === 0) { - return; + if (toLoad.length === 0) { + return null; + } + if (policyengineVersion === null) { + return Promise.reject(new Error('The model catalog has not loaded yet.')); } - await queryClient.fetchQuery({ - queryKey: catalogParametersKey(countryId, policyengineVersion, toLoad), - queryFn: () => loadIntoStore(dispatch, countryId, policyengineVersion, toLoad), - staleTime: Infinity, - gcTime: PARAMETER_QUERY_GC_TIME, - }); + return queryClient + .fetchQuery({ + queryKey: catalogParametersKey(countryId, policyengineVersion, toLoad), + queryFn: () => loadIntoStore(dispatch, countryId, policyengineVersion, toLoad), + staleTime: Infinity, + gcTime: PARAMETER_QUERY_GC_TIME, + }) + .then(() => undefined); }, [countryId, dispatch, store, queryClient] ); @@ -148,8 +168,9 @@ function useV2LoadParameters(): (names: readonly string[]) => Promise { /** * A function that loads the named parameters into the store and resolves - * once they are in (at once on the v1 routes, where they always are), for - * actions such as adding a parameter to a draft. + * once they are in, for actions such as adding a parameter to a draft. It + * answers null when they are in already, always so on the v1 routes, so + * the action can go ahead at once. */ -export const useLoadV3Parameters: () => (names: readonly string[]) => Promise = +export const useLoadV3Parameters: () => LoadV3Parameters = V3_API_ROUTES === 'v2' ? useV2LoadParameters : useV1LoadParameters; diff --git a/app/src/libs/flagship/uprating.ts b/app/src/libs/flagship/uprating.ts index d54b4bb80..d107517af 100644 --- a/app/src/libs/flagship/uprating.ts +++ b/app/src/libs/flagship/uprating.ts @@ -107,6 +107,11 @@ export function growthOf( return provision?.growth ?? 'fixed'; } +/** The parameter paths of the indexes a value can grow with in this country. */ +export function growthIndexPaths(countryId: string): string[] { + return (INDEXES[countryId] ?? []).map((index) => index.path); +} + export function availableIndexes( countryId: string, parameters: ParameterMetadataCollection diff --git a/app/src/libs/flagship/v3ParameterNames.ts b/app/src/libs/flagship/v3ParameterNames.ts new file mode 100644 index 000000000..dca1b708f --- /dev/null +++ b/app/src/libs/flagship/v3ParameterNames.ts @@ -0,0 +1,62 @@ +import { groupKeyFromPath } from '@/libs/flagship/parameterGroups'; +import { growthIndexPaths } from '@/libs/flagship/uprating'; +import type { Reform } from '@/types/ingredients/Reform'; + +/** + * Which parameters a v3 view needs from the metadata store, so that on the + * v2 routes, where the store holds only what has been loaded, the view can + * load them in one request before it shows (see useEnsureV3Parameters). + */ + +const pathsByGroupKey = new WeakMap, Map>(); + +function groupIndex(addablePaths: Set): Map { + let index = pathsByGroupKey.get(addablePaths); + if (!index) { + index = new Map(); + for (const path of addablePaths) { + const key = groupKeyFromPath(path); + index.set(key, [...(index.get(key) ?? []), path]); + } + pathsByGroupKey.set(addablePaths, index); + } + return index; +} + +/** + * A parameter and the members of the group the draft editor shows it in + * (a breakdown's other members, a schedule's other brackets), among the + * parameters a draft can take. + */ +export function groupMemberPaths(path: string, addablePaths: Set): string[] { + return [...new Set([path, ...(groupIndex(addablePaths).get(groupKeyFromPath(path)) ?? [])])]; +} + +/** + * What the draft editor reads for a draft: each provision, its group's + * members, the index a provision grows with, and the indexes a value can + * be set to grow with. + */ +export function draftParameterNames( + provisions: ReadonlyArray<{ path: string; growth?: string }>, + addablePaths: Set, + countryId: string +): string[] { + const names = new Set(growthIndexPaths(countryId)); + for (const provision of provisions) { + for (const path of groupMemberPaths(provision.path, addablePaths)) { + names.add(path); + } + if (provision.growth && provision.growth !== 'fixed' && provision.growth !== 'current_law') { + names.add(provision.growth); + } + } + return [...names].sort(); +} + +/** Every parameter the reforms change. */ +export function reformParameterNames(reforms: ReadonlyArray>): string[] { + return [ + ...new Set(reforms.flatMap((reform) => reform.parameters.map((parameter) => parameter.name))), + ].sort(); +} diff --git a/app/src/pages/flagship/Ask.page.tsx b/app/src/pages/flagship/Ask.page.tsx index a4a85a06c..dce685fd1 100644 --- a/app/src/pages/flagship/Ask.page.tsx +++ b/app/src/pages/flagship/Ask.page.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { IconAdjustments, IconChevronRight, @@ -7,7 +7,7 @@ import { IconPlus, } from '@tabler/icons-react'; import ReactMarkdown from 'react-markdown'; -import { useSelector } from 'react-redux'; +import { useSelector, useStore } from 'react-redux'; import remarkGfm from 'remark-gfm'; import { askChatEndpoint, @@ -21,17 +21,16 @@ import { Stack, Text, Title } from '@/components/ui'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useEnsureV3Parameters, useLoadV3Parameters } from '@/hooks/useV3Parameters'; +import { useV3SearchCatalog } from '@/hooks/useV3SearchCatalog'; import { addDraftProvision, provisionFromSearchEntry, useDraftReform } from '@/libs/draftReform'; import { ChatReformBridge, provisionsFromChatReform, reformFromToolInput, } from '@/libs/flagship/chatDraftBridge'; -import { - ParameterSearchEntry, - searchParameters, - selectParameterSearchIndex, -} from '@/libs/parameterSearch'; +import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; +import { ParameterSearchEntry, searchParameters } from '@/libs/parameterSearch'; import { RootState } from '@/store'; import { formatValue, getCurrentValue, parseValueFromQuestion } from '@/utils/parameterValues'; @@ -164,8 +163,21 @@ export default function AskPage() { const nav = useAppNavigate(); const countryId = useCurrentCountry(); const draft = useDraftReform(); - const index = useSelector(selectParameterSearchIndex); + // From the whole metadata on the v1 routes, the search file on v2. + const catalog = useV3SearchCatalog(); + const index = catalog.index; const parameters = useSelector((state: RootState) => state.metadata.parameters); + const store = useStore(); + const loadParameters = useLoadV3Parameters(); + // On the v2 routes the search file loads in the background: until it is + // in, nothing can match, so the page says so instead of answering empty. + const searchLoading = V3_API_ROUTES === 'v2' && catalog.status !== 'ready'; + // Current law for the matches shown, which on the v2 routes load once listed. + const shownMatchPaths = useMemo( + () => turns.flatMap((turn) => (turn.matches ?? []).map((entry) => entry.path)), + [turns] + ); + useEnsureV3Parameters(shownMatchPaths); const examples = EXAMPLES[countryId] ?? EXAMPLES.default; const draftPaths = new Set(draft?.provisions.map((p) => p.path) ?? []); @@ -239,9 +251,23 @@ export default function AskPage() { if (!reform) { return; } - const bridge = provisionsFromChatReform(reform, index.entries, parameters); - if (bridge.provisions.length > 0) { - patchLastChat((chat) => ({ ...chat, bridge })); + // The draft's current law comes with the reform's parameters, + // which on the v2 routes load first. + const bridgeReform = () => { + const bridge = provisionsFromChatReform( + reform, + index.entries, + store.getState().metadata.parameters + ); + if (bridge.provisions.length > 0) { + patchLastChat((chat) => ({ ...chat, bridge })); + } + }; + const pending = loadParameters(Object.keys(reform)); + if (pending) { + pending.then(bridgeReform, bridgeReform); + } else { + bridgeReform(); } }, onSuggestions: (suggestions) => patchLastChat((chat) => ({ ...chat, suggestions })), @@ -287,7 +313,8 @@ export default function AskPage() { const send = (raw?: string) => { const question = (raw ?? input).trim(); - if (!question) { + // Nothing can match until the search file is in; the question waits in the box. + if (!question || searchLoading) { return; } setInput(''); @@ -305,14 +332,26 @@ export default function AskPage() { }; const addMatch = (entry: ParameterSearchEntry, question: string) => { - const provision = provisionFromSearchEntry(entry, parameters?.[entry.path]?.values); - const proposed = parseValueFromQuestion(question, entry.unit); - addDraftProvision( - countryId, - proposed === undefined ? provision : { ...provision, value: proposed }, - 'chat', - 'ask-keyword-v0' - ); + const add = () => { + const provision = provisionFromSearchEntry( + entry, + store.getState().metadata.parameters?.[entry.path]?.values + ); + const proposed = parseValueFromQuestion(question, entry.unit); + addDraftProvision( + countryId, + proposed === undefined ? provision : { ...provision, value: proposed }, + 'chat', + 'ask-keyword-v0' + ); + }; + // A match joins the draft at current law, which on the v2 routes loads first. + const pending = loadParameters([entry.path]); + if (pending) { + void pending.then(add); + } else { + add(); + } }; const renderMatchRow = (entry: ParameterSearchEntry, question: string) => { @@ -500,20 +539,44 @@ export default function AskPage() { }; const composer = ( - send()} - examples={examples} - animatePlaceholder={!hasTurns} - note={ - chatEnabled - ? countryId === 'uk' - ? 'Answers computed live by the PolicyEngine UK model' - : 'AI drafting against the policyengine-us parameters — impacts compute when you run the report' - : 'Matches your description to PolicyEngine parameters — pick one to start a draft' - } - /> + <> + {searchLoading && ( +

+ {catalog.status === 'error' ? ( + <> + Could not load the parameters.{' '} + + + ) : ( + 'Loading the parameters to match against…' + )} +

+ )} + send()} + examples={examples} + animatePlaceholder={!hasTurns} + note={ + chatEnabled + ? countryId === 'uk' + ? 'Answers computed live by the PolicyEngine UK model' + : 'AI drafting against the policyengine-us parameters — impacts compute when you run the report' + : 'Matches your description to PolicyEngine parameters — pick one to start a draft' + } + /> + ); if (!hasTurns) { diff --git a/app/src/pages/flagship/BillReport.page.tsx b/app/src/pages/flagship/BillReport.page.tsx index f4de8c076..b3b562e58 100644 --- a/app/src/pages/flagship/BillReport.page.tsx +++ b/app/src/pages/flagship/BillReport.page.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { IconCalendar, IconExternalLink } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; @@ -20,9 +20,9 @@ import { CongressionalDistrictDataProvider } from '@/contexts/CongressionalDistr import { colors, spacing, typography } from '@/designTokens'; import { useBillEconomy, type BillEconomy } from '@/hooks/useBillEconomy'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; -import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; import { useStoredBillDistricts } from '@/hooks/useStoredBillDistricts'; import { useTrackedBills } from '@/hooks/useTrackedBills'; +import { useEnsureV3Parameters } from '@/hooks/useV3Parameters'; import { storedBillMetrics } from '@/libs/flagship/billMetrics'; import { billAlreadyCurrentLaw, @@ -147,17 +147,19 @@ function FullResultsStatus({ runState, economy, onStart, + onRetryParameters, }: { runState: RunState; economy: BillEconomy; onStart: () => void; + /** Asks again for the bill's parameters, after they failed to load. */ + onRetryParameters: () => unknown; }) { const countryId = useCurrentCountry(); - const metadata = useFlagshipMetadataStatus(); if (runState === 'no-metadata') { return ( - + ); } @@ -242,8 +244,6 @@ export default function BillReportPage({ billId: propId }: BillReportPageProps) const billId = propId ?? params.billId ?? ''; const countryId = useCurrentCountry(); const parameters = useSelector((state: RootState) => state.metadata.parameters); - const metadataLoaded = useSelector((state: RootState) => state.metadata.version !== null); - const metadataError = useSelector((state: RootState) => state.metadata.error); const feed = useTrackedBills(countryId); const { bills } = feed; // The bill whose full run the reader started; another bill starts unrun. @@ -252,6 +252,15 @@ export default function BillReportPage({ billId: propId }: BillReportPageProps) // The tracker links here by its bill id; an encoded or upper-case id finds the same bill. const bill: TrackedBill | undefined = findTrackedBill(bills, billId); + // Names and current law for the bill's provisions: with the whole + // metadata on the v1 routes; on the v2 routes they load in one request. + const billNames = useMemo( + () => (bill?.provisions ?? []).map((provision) => provision.path), + [bill] + ); + const billParameters = useEnsureV3Parameters(billNames); + const metadataLoaded = billParameters.status === 'ready'; + const metadataError = billParameters.status === 'error'; const { data: storedDistricts } = useStoredBillDistricts(bill?.id); // A bill from a later year runs, and reads against the law, in its first year. @@ -339,16 +348,33 @@ export default function BillReportPage({ billId: propId }: BillReportPageProps) output={runState === 'complete' ? economy.output : null} storedMetrics={storedBillMetrics(bill, countryId)} storedMetricsNote={STORED_NOTE[runState]} - pending={} + pending={ + + } economyPending={ - + } districtsPending={ - + {storedDistricts && storedDistricts.length > 0 && ( // The tracker's districts, until the full results bring their own. state.metadata.parameters); + const store = useStore(); const draft = useDraftReform(); // Each country keeps its own draft: one set aside while another // country's was built comes back on its own country's page. @@ -41,16 +44,43 @@ export default function BuildPage() { const current = draft && draft.countryId === countryId ? draft : null; // Until the index is in, the add bar has nothing to search and the // table can't group breakdowns (their members would show apart, then - // merge), so the page waits on one loader rather than two. - const loading = useSelector(selectParameterSearchEntries).length === 0; + // merge), so the page waits on one loader rather than two. On the v2 + // routes it waits for the draft's parameters too (with their groups and + // the indexes a value can grow with), which load in one request. + const catalog = useV3SearchCatalog(); + const provisions = current?.provisions; + const draftNames = useMemo( + () => (provisions ? draftParameterNames(provisions, catalog.addablePaths, countryId) : []), + [provisions, catalog.addablePaths, countryId] + ); + const draftParameters = useEnsureV3Parameters(draftNames); + const loader = + catalog.status !== 'ready' + ? catalog + : draftParameters.status !== 'ready' + ? draftParameters + : null; + const loadParameters = useLoadV3Parameters(); + const [pickError, setPickError] = useState(null); + // A picked parameter joins the draft at current law, so on the v2 + // routes its values (and its group's) load first; on v1 they are in. const pick = (entry: ParameterSearchEntry) => { - addDraftProvision( - countryId, - provisionFromSearchEntry(entry, parameters?.[entry.path]?.values), - 'manual' - ); - focusProvision(entry.path); + const add = () => { + setPickError(null); + addDraftProvision( + countryId, + provisionFromSearchEntry(entry, store.getState().metadata.parameters?.[entry.path]?.values), + 'manual' + ); + focusProvision(entry.path); + }; + const pending = loadParameters(groupMemberPaths(entry.path, catalog.addablePaths)); + if (pending) { + pending.then(add, () => setPickError('Could not load that parameter. Try picking it again.')); + } else { + add(); + } }; return ( @@ -71,8 +101,11 @@ export default function BuildPage() { {current ? ( <> - {loading ? ( - provision.path)} /> + {loader ? ( + provision.path)} + loader={loader} + /> ) : ( // The next parameter is added at the foot of the reform, where // the last one ends: a reform takes as many as it needs. @@ -88,6 +121,11 @@ export default function BuildPage() { )} + {pickError && ( +

+ {pickError} +

+ )} ); diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index 1074e0a52..739bbb98e 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -40,6 +40,7 @@ import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useFlagshipReportStore, useReformStore, useV3SavedWork } from '@/hooks/useFlagshipStores'; import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport'; import { useTrackedBills } from '@/hooks/useTrackedBills'; +import { useEnsureV3Parameters } from '@/hooks/useV3Parameters'; import { loadProvisionsIntoDraft, provisionsToRun, @@ -57,6 +58,7 @@ import { dayLabel, periodLabel } from '@/libs/flagship/draftLabels'; import { latestMatchingReport } from '@/libs/flagship/latestReport'; import { rerunSettings } from '@/libs/flagship/reportRerun'; import { flagshipReportQueryKeys } from '@/libs/flagship/runReport'; +import { reformParameterNames } from '@/libs/flagship/v3ParameterNames'; import { RootState } from '@/store'; import { Reform, ReformSource } from '@/types/ingredients/Reform'; import { ValueIntervalCollection } from '@/types/subIngredients/valueInterval'; @@ -501,6 +503,17 @@ export default function ReformsPage() { const selectedReform = selectedId ? (reforms ?? []).find((reform) => reform.id === selectedId) : null; + // The detail's names and current law: on the v2 routes the store holds + // only what has been loaded, so the reform's (or bill's) parameters load + // in one request when it opens. + const viewedNames = useMemo( + () => [ + ...(selectedReform ? reformParameterNames([selectedReform]) : []), + ...(selectedBill ? selectedBill.provisions.map((provision) => provision.path) : []), + ], + [selectedReform, selectedBill] + ); + const viewedParameters = useEnsureV3Parameters(viewedNames); const openReform = (reform: Reform) => { // A failed delete or re-run belongs to the reform it was tried on. @@ -720,7 +733,7 @@ export default function ReformsPage() { const reformName = selectedReform.label || 'Untitled reform'; const count = selectedReform.parameters.length; // The store starts with no parameters; names and current law need them. - const metadataReady = !!parameters && Object.keys(parameters).length > 0; + const metadataReady = viewedParameters.status === 'ready'; // What the report would run, as Build runs it (dated changes // included); a reform that changes nothing has no report to run. // Run in the reform's own year: its first change, or this year once that is past. diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 1070176fd..6b82f74e6 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -26,7 +26,6 @@ import { Stack, Title } from '@/components/ui'; import { getDurationForRegion } from '@/constants/calculationDurations'; import { colors, spacing } from '@/designTokens'; import { useCalculationStatus } from '@/hooks/useCalculationStatus'; -import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; import { isApiReportId, useFlagshipReport } from '@/hooks/useFlagshipReport'; import { useFlagshipReportStore, @@ -38,6 +37,7 @@ import { useReportValidationSnapshot } from '@/hooks/useReportValidationSnapshot import { useReportYearEconomies } from '@/hooks/useReportYearEconomy'; import { useStartCalculationOnLoad } from '@/hooks/useStartCalculationOnLoad'; import { useUnreadableReportMeta } from '@/hooks/useUnreadableReportMeta'; +import { useEnsureV3Parameters } from '@/hooks/useV3Parameters'; import { clearDraftAfterRun, loadProvisionsIntoDraft } from '@/libs/draftReform'; import { isUntitledReform, @@ -146,6 +146,22 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep const reformPolicyId = simulations?.[1]?.policyId; const reformPolicy = policies.find((policy) => policy.id === reformPolicyId); const baselinePolicyId = simulations?.[0]?.policyId; + const baselinePolicy = policies.find((policy) => policy.id === baselinePolicyId); + // Names and current law for what the report changes (and what it is + // compared with): with the whole metadata on the v1 routes; on the v2 + // routes they load in one request once the report's policies are in. + const storedMeta = readReportMeta(userReportId); + const reportParameterNames = useMemo( + () => [ + ...new Set([ + ...(storedMeta?.provisions.map((provision) => provision.path) ?? []), + ...(reformPolicy?.parameters?.map((parameter) => parameter.name) ?? []), + ...(baselinePolicy?.parameters?.map((parameter) => parameter.name) ?? []), + ]), + ], + [storedMeta, reformPolicy, baselinePolicy] + ); + const reportParameters = useEnsureV3Parameters(reportParameterNames); // The saved report names the reform it was compared with; only its owner // links to that reform. const sharedBaseline = @@ -157,10 +173,11 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep } : undefined; const meta = - readReportMeta(userReportId) ?? + storedMeta ?? reportMetaFromRecord(shared?.record, report?.year, sharedBaseline) ?? provenanceFromPolicy( - reformPolicy, + // Rebuilt from the policy once its parameters' names and current law are in. + reportParameters.status === 'ready' ? reformPolicy : undefined, parameters, report?.label, report?.year || undefined, @@ -171,7 +188,7 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep // Stored provenance that can't be read: readReportMeta answers none for it. const unreadableMeta = useUnreadableReportMeta(userReportId); // The reform's provisions are rebuilt from metadata; without it they cannot show. - const metadata = useFlagshipMetadataStatus(); + const metadata = reportParameters; // Rebuilt, the provisions are on their way until the reform policy loads // (it waits for the parameter metadata, so names and current law come // with it): the report waits for them rather than show an empty reform @@ -246,7 +263,6 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep const output = calcStatus.isComplete ? (calcStatus.result as SocietyWideOutput) : null; - const baselinePolicy = policies.find((policy) => policy.id === baselinePolicyId); // A baseline policy that failed to load is unknown, not current law. const baselineUnknown = reportBaselineUnknown(meta, baselinePolicyId, { currentLawId, diff --git a/app/src/tests/fixtures/api/publicApi/publicApiFetchStub.ts b/app/src/tests/fixtures/api/publicApi/publicApiFetchStub.ts index 024836976..77fb49c08 100644 --- a/app/src/tests/fixtures/api/publicApi/publicApiFetchStub.ts +++ b/app/src/tests/fixtures/api/publicApi/publicApiFetchStub.ts @@ -22,7 +22,8 @@ export function stubPublicApiFetch(handlers: Record = {}) ...handlers, }; const fetchStub = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const request = input instanceof Request ? input : new Request(String(input), init); + const request = + input instanceof Request ? input : new Request(absoluteUrl(String(input)), init); const url = new URL(request.url); const handler = routes[url.pathname]; if (!handler) { @@ -34,12 +35,14 @@ export function stubPublicApiFetch(handlers: Record = {}) return fetchStub; } +/** A same-origin path (`/api/…`), as the app's other clients send, made absolute. */ +function absoluteUrl(input: string): string { + return new URL(input, 'http://localhost').toString(); +} + /** The requests the stub received for one path. */ export function requestsTo(fetchStub: ReturnType, path: string): URL[] { return fetchStub.mock.calls - .map( - ([input, init]) => - new URL(input instanceof Request ? input.url : new Request(String(input), init).url) - ) + .map(([input]) => new URL(input instanceof Request ? input.url : absoluteUrl(String(input)))) .filter((url) => url.pathname === path); } diff --git a/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx b/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx new file mode 100644 index 000000000..5e1b79ed5 --- /dev/null +++ b/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx @@ -0,0 +1,54 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import WorkspaceLayout from '@/components/flagship/WorkspaceLayout'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { TEST_POLICYENGINE_VERSION } from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; +import { + jsonResponse, + requestsTo, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { US_SEARCH_FILE } from '@/tests/fixtures/api/publicApi/searchFileMocks'; +import { seedV2ModelCatalog, testQueryClient } from '@/tests/fixtures/hooks/v3MetadataWrapper'; + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +const SEARCH_FILE_PATH = '/v2/parameters/search-file'; + +describe('WorkspaceLayout with v3 on the v2 routes', () => { + let fetchStub: ReturnType; + + beforeEach(async () => { + fetchStub = stubPublicApiFetch({ [SEARCH_FILE_PATH]: () => jsonResponse(US_SEARCH_FILE) }); + await seedV2ModelCatalog(); + }); + + afterEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + test('given a page renders then the search file downloads behind it, for the catalog version', async () => { + // When + render( + + +

Page content

+
+
+ ); + + // Then + expect(screen.getByText('Page content')).toBeInTheDocument(); + await waitFor(() => expect(requestsTo(fetchStub, SEARCH_FILE_PATH)).toHaveLength(1)); + expect( + requestsTo(fetchStub, SEARCH_FILE_PATH)[0].searchParams.get('policyengine_version') + ).toBe(TEST_POLICYENGINE_VERSION); + }); +}); diff --git a/app/src/tests/unit/hooks/useV3ParametersV1.test.tsx b/app/src/tests/unit/hooks/useV3ParametersV1.test.tsx index 5a874c268..58fd196b1 100644 --- a/app/src/tests/unit/hooks/useV3ParametersV1.test.tsx +++ b/app/src/tests/unit/hooks/useV3ParametersV1.test.tsx @@ -28,13 +28,12 @@ describe('useEnsureV3Parameters on the v1 routes (before the switch)', () => { expect(fetchStub).not.toHaveBeenCalled(); }); - test('given the load function then it resolves without a request', async () => { + test('given the load function then it answers null at once, with no request', () => { const fetchStub = stubPublicApiFetch(); seedDeductionMetadata(); const { result } = renderHook(() => useLoadV3Parameters(), { wrapper: v3Wrapper() }); - await result.current(['gov.irs.anything']); - + expect(result.current(['gov.irs.anything'])).toBeNull(); expect(fetchStub).not.toHaveBeenCalled(); }); }); diff --git a/app/src/tests/unit/hooks/useV3ParametersV2.test.tsx b/app/src/tests/unit/hooks/useV3ParametersV2.test.tsx index 2bb7742a9..324ebfaaf 100644 --- a/app/src/tests/unit/hooks/useV3ParametersV2.test.tsx +++ b/app/src/tests/unit/hooks/useV3ParametersV2.test.tsx @@ -150,11 +150,15 @@ describe('useLoadV3Parameters with v3 on the v2 routes', () => { // Then expect(store.getState().metadata.parameters[STANDARD_DEDUCTION_SINGLE]?.values).toBeDefined(); + }); - // And loading them again asks for nothing + test('given names already loaded then the load answers null, so the caller acts at once', async () => { + const { result } = renderHook(() => useLoadV3Parameters(), { wrapper: v3Wrapper() }); await act(async () => { await result.current([STANDARD_DEDUCTION_SINGLE]); }); + + expect(result.current([STANDARD_DEDUCTION_SINGLE])).toBeNull(); expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(1); }); }); diff --git a/app/src/tests/unit/libs/flagship/v3ParameterNames.test.ts b/app/src/tests/unit/libs/flagship/v3ParameterNames.test.ts new file mode 100644 index 000000000..fffb2c121 --- /dev/null +++ b/app/src/tests/unit/libs/flagship/v3ParameterNames.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'vitest'; +import { growthIndexPaths } from '@/libs/flagship/uprating'; +import { + draftParameterNames, + groupMemberPaths, + reformParameterNames, +} from '@/libs/flagship/v3ParameterNames'; + +const SINGLE = 'gov.irs.deductions.standard.amount.SINGLE'; +const JOINT = 'gov.irs.deductions.standard.amount.JOINT'; +const FIRST_BRACKET_RATE = 'gov.irs.income.bracket.rates[0].rate'; +const SECOND_BRACKET_RATE = 'gov.irs.income.bracket.rates[1].rate'; +const CTC_REFUNDABLE = 'gov.irs.credits.ctc.refundable.fully_refundable'; +const ADDABLE = new Set([SINGLE, JOINT, FIRST_BRACKET_RATE, SECOND_BRACKET_RATE, CTC_REFUNDABLE]); + +describe('groupMemberPaths', () => { + test('given a breakdown member then its other members come with it', () => { + expect(groupMemberPaths(SINGLE, ADDABLE).sort()).toEqual([JOINT, SINGLE].sort()); + }); + + test("given a bracket field then the schedule's other brackets come with it", () => { + expect(groupMemberPaths(FIRST_BRACKET_RATE, ADDABLE).sort()).toEqual( + [FIRST_BRACKET_RATE, SECOND_BRACKET_RATE].sort() + ); + }); + + test('given a parameter that stands alone then it is the only name', () => { + expect(groupMemberPaths(CTC_REFUNDABLE, ADDABLE)).toEqual([CTC_REFUNDABLE]); + }); + + test('given the parameters a draft can take are not in yet then the path alone is named', () => { + expect(groupMemberPaths(SINGLE, new Set())).toEqual([SINGLE]); + }); +}); + +describe('draftParameterNames', () => { + test('given a draft then it names provisions, their groups, their growth and the indexes', () => { + const names = draftParameterNames( + [ + { path: SINGLE, growth: 'gov.bls.cpi.cpi_u' }, + { path: CTC_REFUNDABLE, growth: 'fixed' }, + ], + ADDABLE, + 'us' + ); + + expect(names).toEqual( + [...new Set([SINGLE, JOINT, CTC_REFUNDABLE, ...growthIndexPaths('us')])].sort() + ); + }); +}); + +describe('reformParameterNames', () => { + test('given reforms then each changed parameter is named once', () => { + const names = reformParameterNames([ + { parameters: [{ name: SINGLE, values: [] }] }, + { + parameters: [ + { name: SINGLE, values: [] }, + { name: CTC_REFUNDABLE, values: [] }, + ], + }, + ]); + + expect(names).toEqual([CTC_REFUNDABLE, SINGLE]); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/AskV2.test.tsx b/app/src/tests/unit/pages/flagship/AskV2.test.tsx new file mode 100644 index 000000000..d86b21338 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/AskV2.test.tsx @@ -0,0 +1,103 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { clearDraftReform, getDraftReform } from '@/libs/draftReform'; +import AskPage from '@/pages/flagship/Ask.page'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { catalogParametersResult } from '@/tests/fixtures/api/publicApi/catalogParametersMocks'; +import { + jsonResponse, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { + STANDARD_DEDUCTION_JOINT, + STANDARD_DEDUCTION_SINGLE, + US_SEARCH_FILE, +} from '@/tests/fixtures/api/publicApi/searchFileMocks'; +import { seedV2ModelCatalog, testQueryClient } from '@/tests/fixtures/hooks/v3MetadataWrapper'; + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +vi.mock('react-router-dom', async () => ({ + ...(await vi.importActual('react-router-dom')), + useParams: () => ({ countryId: 'us' }), +})); + +const SEARCH_FILE_PATH = '/v2/parameters/search-file'; +const PARAMETERS_PATH = '/v2/model-catalog/parameters'; + +function renderAsk() { + return render( + + + + ); +} + +async function ask(user: ReturnType, question: string) { + await user.type(screen.getByRole('textbox', { name: /policy question/i }), question); + await user.click(screen.getByRole('button', { name: /send/i })); +} + +function answerParameters(url: URL) { + return jsonResponse({ + status: 'ok', + message: null, + result: catalogParametersResult(url.searchParams.getAll('name')), + }); +} + +describe('AskPage with v3 on the v2 routes', () => { + beforeEach(() => { + clearDraftReform(); + }); + + afterEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + test('given the search file is still loading then the page says so and a question waits', async () => { + // Given + stubPublicApiFetch({ [SEARCH_FILE_PATH]: () => new Promise(() => {}) }); + await seedV2ModelCatalog(); + const user = userEvent.setup(); + renderAsk(); + + // When + await ask(user, 'standard deduction'); + + // Then + expect(screen.getByRole('status')).toHaveTextContent('Loading the parameters to match against'); + expect(screen.queryByText(/here's what matched/i)).not.toBeInTheDocument(); + }); + + test('given the search file then matches come from it and a picked one joins at current law', async () => { + // Given + stubPublicApiFetch({ + [SEARCH_FILE_PATH]: () => jsonResponse(US_SEARCH_FILE), + [PARAMETERS_PATH]: answerParameters, + }); + await seedV2ModelCatalog(); + const user = userEvent.setup(); + renderAsk(); + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()); + + // When + await ask(user, 'standard deduction'); + expect(screen.getByText(/here's what matched/i)).toBeInTheDocument(); + const match = await screen.findByRole('button', { name: /single/i }); + await user.click(match); + + // Then + await waitFor(() => expect(getDraftReform()?.provisions).toHaveLength(1)); + const provision = getDraftReform()!.provisions[0]; + expect([STANDARD_DEDUCTION_SINGLE, STANDARD_DEDUCTION_JOINT]).toContain(provision.path); + expect(provision.baselineValue).toBe(15000); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/BillReportV2.test.tsx b/app/src/tests/unit/pages/flagship/BillReportV2.test.tsx new file mode 100644 index 000000000..30e7c359a --- /dev/null +++ b/app/src/tests/unit/pages/flagship/BillReportV2.test.tsx @@ -0,0 +1,167 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { BillEconomy } from '@/hooks/useBillEconomy'; +import BillReportPage from '@/pages/flagship/BillReport.page'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { catalogParametersResult } from '@/tests/fixtures/api/publicApi/catalogParametersMocks'; +import { + jsonResponse, + requestsTo, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { seedV2ModelCatalog } from '@/tests/fixtures/hooks/v3MetadataWrapper'; +import { TRACKED_BILL } from '@/tests/fixtures/libs/flagship/trackedBillMocks'; + +const mockCalibrationMatchesForPaths = vi.fn(); + +vi.mock('@/libs/flagship/calibrationMatching', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + calibrationMatchesForPaths: (...args: unknown[]) => mockCalibrationMatchesForPaths(...args), + }; +}); + +const mockFetchTrackerBills = vi.fn(); +const mockFetchTrackerBillDistricts = vi.fn(); + +vi.mock('@/api/billFeed', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchTrackerBills: () => mockFetchTrackerBills(), + fetchTrackerBillDistricts: (id: string) => mockFetchTrackerBillDistricts(id), + }; +}); + +vi.mock('@/components/flagship/report/DistrictImpactCard', () => ({ + default: ({ districts, modes }: { districts?: unknown[]; modes?: string[] }) => ( +
+ District map: {districts?.length ?? 'full results'} · {modes?.join(', ') ?? 'all views'} +
+ ), +})); + +const mockIsAlreadyCurrentLaw = vi.fn(); + +vi.mock('@/libs/flagship/billMetrics', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isAlreadyCurrentLaw: (...args: Parameters) => + mockIsAlreadyCurrentLaw(...args) ?? actual.isAlreadyCurrentLaw(...args), + }; +}); + +const mockUseBillEconomy = vi.fn(); +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +vi.mock('@/hooks/useBillEconomy', () => ({ + useBillEconomy: (...args: unknown[]) => mockUseBillEconomy(...args), +})); + +vi.mock('@/components/flagship/report/EconomicImpactCharts', () => ({ + default: () =>
Economic impact charts
, +})); + +const mockRetry = vi.fn(); + +function economy(overrides: Partial = {}): BillEconomy { + return { + output: null, + status: 'pending', + reformPolicyId: '98557', + baselinePolicyId: '2', + year: '2026', + region: 'us', + retry: mockRetry, + ...overrides, + }; +} + +function renderReport(billId = 'us-hr1425') { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + ); +} + +const PARAMETERS_PATH = '/v2/model-catalog/parameters'; +const BILL_PARAMETER = TRACKED_BILL.provisions[0].path; + +describe('BillReportPage with v3 on the v2 routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchTrackerBills.mockResolvedValue([TRACKED_BILL]); + mockFetchTrackerBillDistricts.mockResolvedValue(null); + mockUseBillEconomy.mockReturnValue(economy()); + mockCalibrationMatchesForPaths.mockResolvedValue([]); + mockIsAlreadyCurrentLaw.mockReturnValue(undefined); + }); + + afterEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + test("given a bill then its provisions' parameters load in one request", async () => { + // Given + const fetchStub = stubPublicApiFetch({ + [PARAMETERS_PATH]: (url) => + jsonResponse({ + status: 'ok', + message: null, + result: catalogParametersResult(url.searchParams.getAll('name')), + }), + }); + await seedV2ModelCatalog(); + + // When + renderReport(); + + // Then + await waitFor(() => expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(1)); + expect(requestsTo(fetchStub, PARAMETERS_PATH)[0].searchParams.getAll('name')).toEqual([ + BILL_PARAMETER, + ]); + }); + + test('given the parameters failed to load then try again asks for them again', async () => { + // Given + const user = userEvent.setup(); + let attempts = 0; + const fetchStub = stubPublicApiFetch({ + [PARAMETERS_PATH]: (url) => { + attempts += 1; + return attempts === 1 + ? jsonResponse({ status: 'error', message: 'Unavailable' }, 503) + : jsonResponse({ + status: 'ok', + message: null, + result: catalogParametersResult(url.searchParams.getAll('name')), + }); + }, + }); + await seedV2ModelCatalog(); + renderReport(); + await user.click(await screen.findByRole('tab', { name: 'Economic impacts' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Could not load the parameters'); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + await waitFor(() => expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(2)); + await waitFor(() => + expect(screen.queryByText('Could not load the parameters')).not.toBeInTheDocument() + ); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/BuildV2.test.tsx b/app/src/tests/unit/pages/flagship/BuildV2.test.tsx new file mode 100644 index 000000000..c3bf91869 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/BuildV2.test.tsx @@ -0,0 +1,139 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { + addDraftProvision, + clearDraftReform, + getDraftReform, + startDraftReform, +} from '@/libs/draftReform'; +import { growthIndexPaths } from '@/libs/flagship/uprating'; +import BuildPage from '@/pages/flagship/Build.page'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { catalogParametersResult } from '@/tests/fixtures/api/publicApi/catalogParametersMocks'; +import { + jsonResponse, + requestsTo, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { + STANDARD_DEDUCTION_JOINT, + STANDARD_DEDUCTION_SINGLE, + US_SEARCH_FILE, +} from '@/tests/fixtures/api/publicApi/searchFileMocks'; +import { seedV2ModelCatalog, testQueryClient } from '@/tests/fixtures/hooks/v3MetadataWrapper'; + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +const SEARCH_FILE_PATH = '/v2/parameters/search-file'; +const PARAMETERS_PATH = '/v2/model-catalog/parameters'; + +const SINGLE_DEDUCTION = { + path: STANDARD_DEDUCTION_SINGLE, + breadcrumb: 'IRS → Standard deduction → Single', + unit: 'currency-USD', + baselineValue: 15000, + value: 15000, +}; + +function renderPage() { + return render( + + + + ); +} + +function stubCatalog( + searchFile: () => Response | Promise = () => jsonResponse(US_SEARCH_FILE) +) { + return stubPublicApiFetch({ + [SEARCH_FILE_PATH]: searchFile, + [PARAMETERS_PATH]: (url) => + jsonResponse({ + status: 'ok', + message: null, + result: catalogParametersResult(url.searchParams.getAll('name')), + }), + }); +} + +describe('BuildPage with v3 on the v2 routes', () => { + beforeEach(() => { + localStorage.clear(); + clearDraftReform(); + }); + + afterEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + test('given a draft then it waits for its parameters: its own at once, its groups once the search file names them', async () => { + // Given + const fetchStub = stubCatalog(); + await seedV2ModelCatalog(); + startDraftReform('us', 'manual'); + addDraftProvision('us', SINGLE_DEDUCTION); + + // When + renderPage(); + + // Then + expect(screen.getAllByRole('status', { name: 'Loading parameters' })).toHaveLength(1); + await waitFor(() => + expect(screen.queryByRole('status', { name: 'Loading parameters' })).not.toBeInTheDocument() + ); + const requestedNames = requestsTo(fetchStub, PARAMETERS_PATH).map((url) => + url.searchParams.getAll('name').sort() + ); + // While the search file downloads, the draft's own parameters and the + // growth indexes load; the breakdown's other member follows the file. + expect(requestedNames).toEqual([ + [STANDARD_DEDUCTION_SINGLE, ...growthIndexPaths('us')].sort(), + [STANDARD_DEDUCTION_JOINT], + ]); + expect(requestsTo(fetchStub, SEARCH_FILE_PATH)).toHaveLength(1); + expect(screen.getByRole('combobox', { name: 'Search parameters' })).toBeInTheDocument(); + }); + + test('given the search file has not arrived then the page keeps its loading state, with no time limit', async () => { + stubCatalog(() => new Promise(() => {})); + await seedV2ModelCatalog(); + startDraftReform('us', 'manual'); + addDraftProvision('us', SINGLE_DEDUCTION); + + renderPage(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(screen.getAllByRole('status', { name: 'Loading parameters' })).toHaveLength(1); + }); + + test('given a search result is picked then its parameter loads and joins the draft at current law', async () => { + // Given + const user = userEvent.setup(); + stubCatalog(); + await seedV2ModelCatalog(); + renderPage(); + const search = await screen.findByRole('combobox', { name: 'Search parameters' }); + + // When + await user.type(search, 'standard deduction'); + const options = await screen.findAllByRole('option'); + const single = options.find((option) => /single/i.test(option.textContent ?? '')); + await user.click(single ?? options[0]); + + // Then + await waitFor(() => expect(getDraftReform()?.provisions).toHaveLength(1)); + const provision = getDraftReform()!.provisions[0]; + expect([STANDARD_DEDUCTION_SINGLE, STANDARD_DEDUCTION_JOINT]).toContain(provision.path); + expect(provision.baselineValue).toBe( + provision.path === STANDARD_DEDUCTION_SINGLE ? 15000 : 30000 + ); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/ReformsV2.test.tsx b/app/src/tests/unit/pages/flagship/ReformsV2.test.tsx new file mode 100644 index 000000000..c6b879ae6 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/ReformsV2.test.tsx @@ -0,0 +1,117 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { renderWithCountry, screen, userEvent, waitFor, within } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import ReformsPage from '@/pages/flagship/Reforms.page'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { catalogParametersResult } from '@/tests/fixtures/api/publicApi/catalogParametersMocks'; +import { + jsonResponse, + requestsTo, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { STANDARD_DEDUCTION_SINGLE } from '@/tests/fixtures/api/publicApi/searchFileMocks'; +import { seedV2ModelCatalog, testQueryClient } from '@/tests/fixtures/hooks/v3MetadataWrapper'; +import type { Reform } from '@/types/ingredients/Reform'; + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +const mockFindMine = vi.fn(); +vi.mock('@/api/reformStore', async (importOriginal) => ({ + ...(await importOriginal()), + getReformStore: () => ({ findMine: mockFindMine }), +})); + +vi.mock('@/hooks/useRunFlagshipReport', () => ({ + useRunFlagshipReport: () => ({ run: vi.fn(), isRunning: false, error: null }), +})); + +vi.mock('@/api/flagshipReportStore', async (importOriginal) => ({ + ...(await importOriginal()), + getFlagshipReportStore: () => ({ findMine: async () => [] }), +})); + +vi.mock('@/api/billFeed', async (importOriginal) => ({ + ...(await importOriginal()), + fetchTrackerBills: async () => [], +})); + +const PARAMETERS_PATH = '/v2/model-catalog/parameters'; + +const DEDUCTION_REFORM: Reform = { + id: 'rf-1', + countryId: 'us', + label: 'A larger standard deduction', + parameters: [ + { + name: STANDARD_DEDUCTION_SINGLE, + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 20000 }], + }, + ], + baseline: 'current-law', + provenance: { source: 'manual' }, + updatedAt: '2026-08-01T00:00:00Z', +}; + +describe('ReformsPage with v3 on the v2 routes', () => { + let fetchStub: ReturnType; + /** Answers the held parameters request. */ + let releaseParameters: () => void; + + beforeEach(async () => { + const held = new Promise((resolve) => { + releaseParameters = resolve; + }); + fetchStub = stubPublicApiFetch({ + [PARAMETERS_PATH]: async (url) => { + await held; + return jsonResponse({ + status: 'ok', + message: null, + result: catalogParametersResult(url.searchParams.getAll('name')), + }); + }, + }); + await seedV2ModelCatalog(); + mockFindMine.mockResolvedValue([DEDUCTION_REFORM]); + }); + + afterEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + test('given a saved reform opens then its parameters load in one request, rows waiting for them', async () => { + // Given + const user = userEvent.setup(); + renderWithCountry( + + + , + 'us', + '/us/reforms?filter=yours' + ); + + // When + await user.click(await screen.findByText('A larger standard deduction')); + + // Then + const table = screen.getByRole('table', { name: 'Parameters' }); + expect(within(table).getByRole('status', { name: 'Loading parameters' })).toBeInTheDocument(); + releaseParameters(); + await waitFor(() => + expect( + within(table).queryByRole('status', { name: 'Loading parameters' }) + ).not.toBeInTheDocument() + ); + const requests = requestsTo(fetchStub, PARAMETERS_PATH); + expect(requests).toHaveLength(1); + expect(requests[0].searchParams.getAll('name')).toEqual([STANDARD_DEDUCTION_SINGLE]); + // Current law, from the catalog: $15,000 since 2025. + expect(within(table).getByText('$15,000')).toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/ReportV2.test.tsx b/app/src/tests/unit/pages/flagship/ReportV2.test.tsx new file mode 100644 index 000000000..92fdc9f63 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/ReportV2.test.tsx @@ -0,0 +1,172 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { FlagshipReportData } from '@/hooks/useFlagshipReport'; +import FlagshipReportPage from '@/pages/flagship/Report.page'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { catalogParametersResult } from '@/tests/fixtures/api/publicApi/catalogParametersMocks'; +import { + jsonResponse, + requestsTo, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { STANDARD_DEDUCTION_SINGLE } from '@/tests/fixtures/api/publicApi/searchFileMocks'; +import { + EXISTING_REPORT, + EXISTING_REPORT_ID, + EXISTING_SIMULATIONS, +} from '@/tests/fixtures/hooks/useFlagshipReportMocks'; +import { seedV2ModelCatalog } from '@/tests/fixtures/hooks/v3MetadataWrapper'; +import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; +import type { Policy } from '@/types/ingredients/Policy'; + +const mockUseFlagshipReport = vi.fn(); +const mockRetry = vi.fn(); +const mockCalcStatus = vi.fn(); +const mockStartCalculationOnLoad = vi.fn(); + +vi.mock('@/hooks/useFlagshipReport', () => ({ + isApiReportId: () => true, + useFlagshipReport: () => mockUseFlagshipReport(), +})); + +vi.mock('@/hooks/useCalculationStatus', () => ({ + useCalculationStatus: () => mockCalcStatus(), +})); + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +vi.mock('@/hooks/useStartCalculationOnLoad', () => ({ + useStartCalculationOnLoad: (args: unknown) => mockStartCalculationOnLoad(args), +})); + +vi.mock('@/hooks/useReportValidationSnapshot', () => ({ + useReportValidationSnapshot: () => undefined, +})); + +vi.mock('@/components/flagship/CalibrationMatches', async (importOriginal) => ({ + ...(await importOriginal()), + useCalibrationMatches: () => null, +})); + +vi.mock('@/components/flagship/ValidationPanel', async (importOriginal) => ({ + ...(await importOriginal()), + useModelTrackRecord: () => null, +})); + +/** What the report hook returns before anything has loaded. */ +function reportData(overrides: Partial = {}): FlagshipReportData { + return { + report: undefined, + simulations: [], + policies: [], + isLoading: false, + error: null, + notFound: false, + failedPolicyIds: [], + retry: mockRetry, + ...overrides, + }; +} + +function renderPage(userReportId: string) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + ); +} + +const PARAMETERS_PATH = '/v2/model-catalog/parameters'; +const COMPLETE_STATUS = { + status: 'complete', + isComplete: true, + isError: false, + result: createMockSocietyWideOutput(), +}; + +/** The report's reform policy, as v1 stores it: a larger standard deduction. */ +const REFORM_POLICY: Policy = { + id: '98557', + countryId: 'us', + parameters: [ + { + name: STANDARD_DEDUCTION_SINGLE, + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 20000 }], + }, + ], +}; + +function answerParameters(url: URL) { + return jsonResponse({ + status: 'ok', + message: null, + result: catalogParametersResult(url.searchParams.getAll('name')), + }); +} + +describe('FlagshipReportPage with v3 on the v2 routes', () => { + beforeEach(async () => { + vi.clearAllMocks(); + store.dispatch(clearMetadata()); + mockCalcStatus.mockReturnValue(COMPLETE_STATUS); + mockUseFlagshipReport.mockReturnValue( + reportData({ + report: EXISTING_REPORT, + simulations: EXISTING_SIMULATIONS, + policies: [REFORM_POLICY], + }) + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("given the report's policies then its reform's parameters load in one request", async () => { + // Given + const fetchStub = stubPublicApiFetch({ [PARAMETERS_PATH]: answerParameters }); + await seedV2ModelCatalog(); + + // When + renderPage(EXISTING_REPORT_ID); + + // Then + await waitFor(() => expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(1)); + expect(requestsTo(fetchStub, PARAMETERS_PATH)[0].searchParams.getAll('name')).toEqual([ + STANDARD_DEDUCTION_SINGLE, + ]); + }); + + test('given the parameters failed then the overview says so and try again asks again', async () => { + // Given + const user = userEvent.setup(); + let attempts = 0; + const fetchStub = stubPublicApiFetch({ + [PARAMETERS_PATH]: (url) => { + attempts += 1; + return attempts === 1 + ? jsonResponse({ status: 'error', message: 'Unavailable' }, 503) + : answerParameters(url); + }, + }); + await seedV2ModelCatalog(); + renderPage(EXISTING_REPORT_ID); + expect(await screen.findByRole('alert')).toHaveTextContent('Could not load the parameters'); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + await waitFor(() => expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(2)); + await waitFor(() => + expect(screen.queryByText('Could not load the parameters')).not.toBeInTheDocument() + ); + }); +}); From 6412ecd49d3e506063169e13aa3c0c04ef0ac3b3 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:37:47 +0400 Subject: [PATCH 06/28] Hold v3's pages behind a short loading page only while the model catalog loads On the v2 routes a v3 page (Ask, Build, Your reforms) now waits for the small `/v2/model-catalog` behind one short loading page, and says "Could not load the model" with a retry if it fails. Everything else the page shows waits in its own skeletons, with no time limit, as before. On the v1 routes the page shows at once, as today. Co-Authored-By: Claude Opus 5.5 --- .../components/flagship/ModelCatalogGate.tsx | 50 ++++++++++++++ .../components/flagship/WorkspaceLayout.tsx | 7 +- .../flagship/WorkspaceLayoutV2.test.tsx | 65 ++++++++++++++++++- 3 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 app/src/components/flagship/ModelCatalogGate.tsx diff --git a/app/src/components/flagship/ModelCatalogGate.tsx b/app/src/components/flagship/ModelCatalogGate.tsx new file mode 100644 index 000000000..f3040a863 --- /dev/null +++ b/app/src/components/flagship/ModelCatalogGate.tsx @@ -0,0 +1,50 @@ +import { ErrorState } from '@/components/flagship/states'; +import { Spinner } from '@/components/ui'; +import { colors, spacing, typography } from '@/designTokens'; +import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; +import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; + +/** + * On the v2 routes, a v3 page waits for the small model catalog (the + * version, regions, years and entities it is built on) behind a short + * loading page, the only one: everything else it shows waits in its own + * skeletons. A failure says so and asks again. On the v1 routes the page + * shows at once, as before, with its skeletons standing for the whole + * metadata. + */ +function V2ModelCatalogGate({ children }: { children: React.ReactNode }) { + const metadata = useFlagshipMetadataStatus(); + if (metadata.status === 'error') { + return ( +
+ +
+ ); + } + if (metadata.status === 'loading') { + return ( +
+ +
+ ); + } + return <>{children}; +} + +function V1ModelCatalogGate({ children }: { children: React.ReactNode }) { + return <>{children}; +} + +export default V3_API_ROUTES === 'v2' ? V2ModelCatalogGate : V1ModelCatalogGate; diff --git a/app/src/components/flagship/WorkspaceLayout.tsx b/app/src/components/flagship/WorkspaceLayout.tsx index 82818b94e..7ec072b26 100644 --- a/app/src/components/flagship/WorkspaceLayout.tsx +++ b/app/src/components/flagship/WorkspaceLayout.tsx @@ -1,3 +1,4 @@ +import ModelCatalogGate from '@/components/flagship/ModelCatalogGate'; import { spacing } from '@/designTokens'; import { useV3SearchFileInBackground } from '@/hooks/useV3SearchCatalog'; @@ -18,5 +19,9 @@ export default function WorkspaceLayout({ children, wide = false }: WorkspaceLay // rendered, so it is in by the time someone searches (nothing on v1). useV3SearchFileInBackground(); - return
{children}
; + return ( +
+ {children} +
+ ); } diff --git a/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx b/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx index 5e1b79ed5..53c21372c 100644 --- a/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx +++ b/app/src/tests/unit/components/flagship/WorkspaceLayoutV2.test.tsx @@ -1,10 +1,13 @@ import { QueryClientProvider } from '@tanstack/react-query'; -import { render, screen, waitFor } from '@test-utils'; +import { render, screen, userEvent, waitFor } from '@test-utils'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import WorkspaceLayout from '@/components/flagship/WorkspaceLayout'; -import { clearMetadata } from '@/reducers/metadataReducer'; +import { clearMetadata, fetchMetadataThunk, metadataRequest } from '@/reducers/metadataReducer'; import { store } from '@/store'; -import { TEST_POLICYENGINE_VERSION } from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; +import { + modelCatalogEnvelope, + TEST_POLICYENGINE_VERSION, +} from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; import { jsonResponse, requestsTo, @@ -52,3 +55,59 @@ describe('WorkspaceLayout with v3 on the v2 routes', () => { ).toBe(TEST_POLICYENGINE_VERSION); }); }); + +function renderLayout() { + return render( + + +

Page content

+
+
+ ); +} + +describe('WorkspaceLayout while the model catalog loads, on the v2 routes', () => { + afterEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + test('given the model catalog is loading then a short loading page stands for the page', () => { + // Given + stubPublicApiFetch({ '/v2/model-catalog': () => new Promise(() => {}) }); + store.dispatch(clearMetadata()); + void store.dispatch(fetchMetadataThunk(metadataRequest('us', 'v2'))); + + // When + renderLayout(); + + // Then + expect(screen.getByRole('status', { name: 'Loading the model' })).toBeInTheDocument(); + expect(screen.queryByText('Page content')).not.toBeInTheDocument(); + }); + + test('given the model catalog failed then the page says so and try again asks again', async () => { + // Given + const user = userEvent.setup(); + let attempts = 0; + const fetchStub = stubPublicApiFetch({ + '/v2/model-catalog': () => { + attempts += 1; + return attempts === 1 + ? jsonResponse({ status: 'error', message: 'Unavailable' }, 503) + : jsonResponse(modelCatalogEnvelope()); + }, + }); + store.dispatch(clearMetadata()); + await store.dispatch(fetchMetadataThunk(metadataRequest('us', 'v2'))); + renderLayout(); + expect(screen.getByRole('alert')).toHaveTextContent('Could not load the model'); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(await screen.findByText('Page content')).toBeInTheDocument(); + expect(requestsTo(fetchStub, '/v2/model-catalog')).toHaveLength(2); + }); +}); From f08c41bdd674bf27dcabe0cf19f2ce913e42f185 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:42:19 +0400 Subject: [PATCH 07/28] Browse v3's parameter tree one level at a time from /v2/parameters/children On the v2 routes there is no whole parameter tree in the store, so the add bar's policy browser now reads the tree one level at a time from `GET /v2/parameters/children`, listing only what a reform can change: the top level when it opens, then each folder as it is opened, one request a level (every page of it), each kept for the session. The trail's labels come from the levels above the open folder; a folder opened from search loads them. A folder's parameters load their current law in one request so the rows show it. Placeholders stand for a level until it arrives, with no time limit, and a failure offers a retry. The browser's markup moved into `PolicyBrowserView`, shared by the whole-tree browser (the v1 routes, unchanged) and the on-demand one; `V3PolicyBrowser` picks between them by `V3_API_ROUTES`. Co-Authored-By: Claude Opus 5.5 --- app/src/api/publicApi/parameterChildren.ts | 45 +++++ .../flagship/policyBrowser/PolicyBrowser.tsx | 134 +++++++++---- .../policyBrowser/V3PolicyBrowser.tsx | 186 ++++++++++++++++++ .../flagship/reform/AddParameterBar.tsx | 6 +- .../api/publicApi/parameterChildrenMocks.ts | 65 ++++++ .../api/publicApi/parameterChildren.test.ts | 62 ++++++ .../policyBrowser/V3PolicyBrowserV2.test.tsx | 162 +++++++++++++++ 7 files changed, 613 insertions(+), 47 deletions(-) create mode 100644 app/src/api/publicApi/parameterChildren.ts create mode 100644 app/src/components/flagship/policyBrowser/V3PolicyBrowser.tsx create mode 100644 app/src/tests/fixtures/api/publicApi/parameterChildrenMocks.ts create mode 100644 app/src/tests/unit/api/publicApi/parameterChildren.test.ts create mode 100644 app/src/tests/unit/components/flagship/policyBrowser/V3PolicyBrowserV2.test.tsx diff --git a/app/src/api/publicApi/parameterChildren.ts b/app/src/api/publicApi/parameterChildren.ts new file mode 100644 index 000000000..310a8e122 --- /dev/null +++ b/app/src/api/publicApi/parameterChildren.ts @@ -0,0 +1,45 @@ +import { createPublicApiClient, requireResult, type PublicApiClient } from '@/api/publicApi/client'; +import type { components } from '@/api/publicApi/schema'; + +/** + * One level of v3's parameter tree, from `GET /v2/parameters/children`: + * a node's direct children that a reform can change, with their labels, + * how many changeable parameters each holds, and, for a parameter, its + * summary. A bracket schedule is one node whose children are its brackets. + */ +export type ParameterChild = components['schemas']['MetadataParameterChild']; + +/** The largest page the route serves. */ +const CHILDREN_PAGE_SIZE = 500; +/** A generous bound so a misbehaving server cannot loop forever. */ +const MAXIMUM_CHILDREN_PAGES = 50; + +export async function fetchParameterChildren( + countryId: string, + policyengineVersion: string, + parentPath: string, + client: PublicApiClient = createPublicApiClient() +): Promise { + const children: ParameterChild[] = []; + for (let page = 0; page < MAXIMUM_CHILDREN_PAGES; page += 1) { + const result = await requireResult( + client.GET('/v2/parameters/children', { + params: { + query: { + country_id: countryId as 'us' | 'uk', + policyengine_version: policyengineVersion, + parent_path: parentPath, + reform_changeable: true, + offset: children.length, + limit: CHILDREN_PAGE_SIZE, + }, + }, + }) + ); + children.push(...result.items); + if (!result.has_more || result.items.length === 0) { + break; + } + } + return children; +} diff --git a/app/src/components/flagship/policyBrowser/PolicyBrowser.tsx b/app/src/components/flagship/policyBrowser/PolicyBrowser.tsx index ce7fcc703..60a5af69e 100644 --- a/app/src/components/flagship/policyBrowser/PolicyBrowser.tsx +++ b/app/src/components/flagship/policyBrowser/PolicyBrowser.tsx @@ -21,34 +21,45 @@ interface PolicyBrowserProps { valueFor: (path: string) => string | null; } +/** What the policy browser shows, however the tree was read. */ +export interface PolicyBrowserViewProps { + /** Shown in place of the tree until the level is in (null once it is). */ + placeholder: React.ReactNode | null; + /** The top level's folders. */ + roots: ParameterTreeNode[]; + /** The open folder's trail from the top level, itself last; empty at the top level. */ + trail: ParameterTreeNode[]; + folders: ParameterTreeNode[]; + parameters: ParameterTreeNode[]; + /** How many parameters a reform can change at or below a folder. */ + count: (path: string) => number; + draftPaths: Set; + onNavigate: (path: string | null) => void; + onAdd: (path: string) => void; + valueFor: (path: string) => string | null; +} + /** * The policy tree as a first-class way in, beside search. The top level * is a grid of programs; inside one, a folder is a breadcrumb, its name, * and a single list — subfolders that open, then parameters that add. * One way to move (the breadcrumb), one list to read. */ -export default function PolicyBrowser({ - tree, - addablePaths, +export function PolicyBrowserView({ + placeholder, + roots, + trail, + folders, + parameters, + count, draftPaths, - path, onNavigate, onAdd, valueFor, -}: PolicyBrowserProps) { +}: PolicyBrowserViewProps) { const contentRef = useRef(null); - const index = useMemo( - () => (tree ? buildPolicyTreeIndex(tree, addablePaths) : null), - [tree, addablePaths] - ); - // A path the tree does not hold (stale, or a search folder the tree - // groups differently) falls back to the top level rather than a blank. - const folder = path && index?.nodes.get(path) ? path : null; - const trail = useMemo(() => (index && folder ? folderTrail(index, folder) : []), [index, folder]); - const contents = useMemo( - () => (index && folder ? folderContents(index, folder) : null), - [index, folder] - ); + const current = trail[trail.length - 1]; + const ancestors = trail.slice(0, -1); // Deep in a long folder, opening the next one should show its top. useEffect(() => { @@ -56,13 +67,7 @@ export default function PolicyBrowser({ if (content && content.getBoundingClientRect().top < 0) { content.scrollIntoView({ block: 'start', behavior: 'smooth' }); } - }, [folder]); - - const count = (node: string) => index?.counts.get(node) ?? 0; - const current = trail[trail.length - 1]; - const ancestors = trail.slice(0, -1); - const folders = contents?.folders ?? []; - const parameters = contents?.parameters ?? []; + }, [current?.name]); return (
@@ -72,24 +77,9 @@ export default function PolicyBrowser({ Browse the policy tree - {!index ? ( -
- - Loading the policy tree… -
- ) : !folder || !current ? ( + {placeholder ? ( + placeholder + ) : !current ? (
- {index.roots.map((node) => ( + {roots.map((node) => ( (tree ? buildPolicyTreeIndex(tree, addablePaths) : null), + [tree, addablePaths] + ); + // A path the tree does not hold (stale, or a search folder the tree + // groups differently) falls back to the top level rather than a blank. + const folder = path && index?.nodes.get(path) ? path : null; + const trail = useMemo(() => (index && folder ? folderTrail(index, folder) : []), [index, folder]); + const contents = useMemo( + () => (index && folder ? folderContents(index, folder) : null), + [index, folder] + ); + + return ( + + + Loading the policy tree… +
+ ) + } + roots={index?.roots ?? []} + trail={trail} + folders={contents?.folders ?? []} + parameters={contents?.parameters ?? []} + count={(node) => index?.counts.get(node) ?? 0} + draftPaths={draftPaths} + onNavigate={onNavigate} + onAdd={onAdd} + valueFor={valueFor} + /> + ); +} + function Crumb({ label, onOpen }: { label: string; onOpen: () => void }) { return (
  • diff --git a/app/src/components/flagship/policyBrowser/V3PolicyBrowser.tsx b/app/src/components/flagship/policyBrowser/V3PolicyBrowser.tsx new file mode 100644 index 000000000..2ecba4606 --- /dev/null +++ b/app/src/components/flagship/policyBrowser/V3PolicyBrowser.tsx @@ -0,0 +1,186 @@ +import { useMemo } from 'react'; +import { useQueries } from '@tanstack/react-query'; +import { useSelector } from 'react-redux'; +import { fetchParameterChildren, type ParameterChild } from '@/api/publicApi/parameterChildren'; +import { ErrorState, SkeletonLine, SkeletonRegion } from '@/components/flagship/states'; +import { spacing } from '@/designTokens'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useEnsureV3Parameters } from '@/hooks/useV3Parameters'; +import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; +import type { RootState } from '@/store'; +import type { ParameterTreeNode } from '@/types/metadata'; +import PolicyBrowser, { PolicyBrowserView } from './PolicyBrowser'; + +interface V3PolicyBrowserProps { + /** Parameters the draft can take; everything else is left out. */ + addablePaths: Set; + draftPaths: Set; + /** The open folder, or null for the top level. Owned by the page. */ + path: string | null; + onNavigate: (path: string | null) => void; + onAdd: (path: string) => void; + /** Formatted current-law value for a parameter row. */ + valueFor: (path: string) => string | null; +} + +/** The node v3's parameter tree starts from: every program sits under it. */ +const TREE_ROOT = 'gov'; + +/** Numeric-aware, as the whole-tree browser sorts: "Bracket 2" before "Bracket 10". */ +const byLabel = (first: ParameterTreeNode, second: ParameterTreeNode) => + first.label.localeCompare(second.label, undefined, { numeric: true }); + +/** + * The folders from the top level down to `path`, itself last: each part of + * the path that ends before a `.` or a `[` (a schedule's bracket is its + * child), below the tree's root. + */ +function trailPathsOf(path: string): string[] { + const paths: string[] = []; + for (let index = TREE_ROOT.length + 1; index < path.length; index += 1) { + if (path[index] === '.' || path[index] === '[') { + paths.push(path.slice(0, index)); + } + } + return [...paths, path]; +} + +function toNode(child: ParameterChild, index: number): ParameterTreeNode { + const folder = child.type !== 'parameter'; + return { + name: child.path, + label: child.label, + index, + type: folder ? 'parameterNode' : 'parameter', + parameter: child.path, + description: child.parameter?.description ?? null, + unit: child.parameter?.unit ?? null, + // A folder's children are listed when it opens; the marker says it has some. + ...(folder ? { children: [] } : {}), + }; +} + +/** The whole tree the v1 metadata holds. */ +function WholeTreePolicyBrowser(props: V3PolicyBrowserProps) { + const tree = useSelector((state: RootState) => state.metadata.parameterTree); + return ; +} + +/** One level's placeholder rows, held until the level is in, with no time limit. */ +function LevelPlaceholder() { + return ( + +
    + {Array.from({ length: 4 }, (_, index) => ( + + ))} +
    +
    + ); +} + +/** + * The tree on the v2 routes, read one level at a time from + * `/v2/parameters/children` (only what a reform can change): the top level + * at first, then each folder as it opens. The trail's labels come from the + * levels above the open folder, which opening it read already (or which + * load for a folder opened from search). The folder's parameters load + * their current law in one request, so their rows show it. + */ +function OnDemandPolicyBrowser({ + addablePaths, + draftPaths, + path, + onNavigate, + onAdd, + valueFor, +}: V3PolicyBrowserProps) { + const countryId = useCurrentCountry(); + const policyengineVersion = useSelector((state: RootState) => + state.metadata.routes === 'v2' ? (state.metadata.v2Catalog?.policyengineVersion ?? null) : null + ); + const trailPaths = useMemo( + () => (path?.startsWith(`${TREE_ROOT}.`) ? trailPathsOf(path) : []), + [path] + ); + // The top level, then the level each trail folder sits in; the last + // trail folder's own level is the open folder's contents. + const levelPaths = [TREE_ROOT, ...trailPaths]; + const levels = useQueries({ + queries: levelPaths.map((parentPath) => ({ + queryKey: ['v2-parameter-children', countryId, policyengineVersion, parentPath] as const, + queryFn: () => fetchParameterChildren(countryId, policyengineVersion!, parentPath), + enabled: policyengineVersion !== null, + staleTime: Infinity, + })), + }); + + const nodesOf = (level: (typeof levels)[number] | undefined) => (level?.data ?? []).map(toNode); + const rootNodes = nodesOf(levels[0]); + const trail: ParameterTreeNode[] = []; + for (let index = 0; index < trailPaths.length; index += 1) { + const node = nodesOf(levels[index]).find((candidate) => candidate.name === trailPaths[index]); + if (!node) { + break; + } + trail.push(node); + } + // A path the tree does not hold (stale, or a search folder the tree + // groups differently) falls back to the top level rather than a blank. + const folderFound = trailPaths.length > 0 && trail.length === trailPaths.length; + const contents = folderFound ? nodesOf(levels[levels.length - 1]) : []; + const counts = new Map( + levels.flatMap((level) => + (level.data ?? []).map((child) => [child.path, child.child_count ?? 0]) + ) + ); + const folders = contents.filter( + (node) => node.type === 'parameterNode' && (counts.get(node.name) ?? 0) > 0 + ); + const parameters = contents.filter( + (node) => node.type === 'parameter' && addablePaths.has(node.name) + ); + useEnsureV3Parameters(parameters.map((node) => node.name)); + + const failed = levels.find((level) => level.isError); + const shownLevels = folderFound ? levels : levels.slice(0, 1); + const loading = policyengineVersion === null || shownLevels.some((level) => level.isPending); + + return ( + + Promise.all(levels.filter((level) => level.isError).map((level) => level.refetch())) + } + /> + ) : loading ? ( + + ) : null + } + roots={rootNodes + .filter((node) => node.type === 'parameterNode' && (counts.get(node.name) ?? 0) > 0) + .sort(byLabel)} + trail={folderFound ? trail : []} + folders={folders.sort(byLabel)} + parameters={parameters.sort(byLabel)} + count={(node) => counts.get(node) ?? 0} + draftPaths={draftPaths} + onNavigate={onNavigate} + onAdd={onAdd} + valueFor={valueFor} + /> + ); +} + +/** + * The policy browser for the routes v3 uses (V3_API_ROUTES, a constant): + * the whole metadata tree on v1, the tree one level at a time on v2. + */ +const V3PolicyBrowser: (props: V3PolicyBrowserProps) => React.ReactNode = + V3_API_ROUTES === 'v2' ? OnDemandPolicyBrowser : WholeTreePolicyBrowser; + +export default V3PolicyBrowser; diff --git a/app/src/components/flagship/reform/AddParameterBar.tsx b/app/src/components/flagship/reform/AddParameterBar.tsx index 33b743d6b..80b81b48b 100644 --- a/app/src/components/flagship/reform/AddParameterBar.tsx +++ b/app/src/components/flagship/reform/AddParameterBar.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { IconListTree, IconX } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; import ParameterSearchBox from '@/components/flagship/ParameterSearchBox'; -import PolicyBrowser from '@/components/flagship/policyBrowser/PolicyBrowser'; +import V3PolicyBrowser from '@/components/flagship/policyBrowser/V3PolicyBrowser'; import ParametersStatus from '@/components/flagship/reform/ParametersStatus'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; @@ -62,7 +62,6 @@ export default function AddParameterBar({ const stateLabels = useSelector(getStateLabels); // Current-law values, for the parameters loaded so far on the v2 routes. const parameters = useSelector((state: RootState) => state.metadata.parameters); - const parameterTree = useSelector((state: RootState) => state.metadata.parameterTree); const draft = useDraftReform(); const draftPaths = new Set( draft?.countryId === countryId ? draft.provisions.map((p) => p.path) : [] @@ -249,8 +248,7 @@ export default function AddParameterBar({ > - = { + gov: [node('gov.irs', 'IRS', 3)], + 'gov.irs': [node('gov.irs.credits', 'Credits', 1), node('gov.irs.deductions', 'Deductions', 2)], + 'gov.irs.credits': [node('gov.irs.credits.ctc', 'Child Tax Credit', 1)], + 'gov.irs.credits.ctc': [node('gov.irs.credits.ctc.refundable', 'Refundable', 1)], + 'gov.irs.credits.ctc.refundable': [parameter(CTC_FULLY_REFUNDABLE, 'Fully refundable')], + 'gov.irs.deductions': [node('gov.irs.deductions.standard', 'Standard deduction', 2)], + 'gov.irs.deductions.standard': [ + node('gov.irs.deductions.standard.amount', 'Standard deduction amount', 2), + ], + 'gov.irs.deductions.standard.amount': [ + parameter(STANDARD_DEDUCTION_JOINT, 'Joint'), + parameter(STANDARD_DEDUCTION_SINGLE, 'Single'), + ], +}; + +/** One page of a node's children. */ +export function childrenPage(parentPath: string, offset = 0, limit = 500) { + const items = PARAMETER_CHILDREN[parentPath] ?? []; + return { + status: 'ok', + message: null, + result: { + policyengine_version: TEST_POLICYENGINE_VERSION, + items: items.slice(offset, offset + limit), + offset, + limit, + has_more: offset + limit < items.length, + }, + }; +} diff --git a/app/src/tests/unit/api/publicApi/parameterChildren.test.ts b/app/src/tests/unit/api/publicApi/parameterChildren.test.ts new file mode 100644 index 000000000..2117bfa62 --- /dev/null +++ b/app/src/tests/unit/api/publicApi/parameterChildren.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test, vi } from 'vitest'; +import { createPublicApiClient } from '@/api/publicApi/client'; +import { fetchParameterChildren } from '@/api/publicApi/parameterChildren'; +import { TEST_POLICYENGINE_VERSION } from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; +import { childrenPage } from '@/tests/fixtures/api/publicApi/parameterChildrenMocks'; +import { jsonResponse } from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { TEST_API_BASE } from '@/tests/fixtures/api/v2UserMocks'; + +function childrenClient(pageSize?: number) { + const fetchMock = vi.fn(async (request: Request) => { + const url = new URL(request.url); + return jsonResponse( + childrenPage( + url.searchParams.get('parent_path') ?? '', + Number(url.searchParams.get('offset') ?? 0), + pageSize ?? Number(url.searchParams.get('limit') ?? 500) + ) + ); + }); + const client = createPublicApiClient({ + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + return { client, fetchMock }; +} + +describe('fetchParameterChildren', () => { + test('given a node then it asks for what a reform can change under it, at the version', async () => { + // Given + const { client, fetchMock } = childrenClient(); + + // When + const children = await fetchParameterChildren( + 'us', + TEST_POLICYENGINE_VERSION, + 'gov.irs', + client + ); + + // Then + const url = new URL((fetchMock.mock.calls[0][0] as Request).url); + expect(url.pathname).toBe('/v2/parameters/children'); + expect(url.searchParams.get('parent_path')).toBe('gov.irs'); + expect(url.searchParams.get('reform_changeable')).toBe('true'); + expect(url.searchParams.get('policyengine_version')).toBe(TEST_POLICYENGINE_VERSION); + expect(children.map((child) => child.path)).toEqual(['gov.irs.credits', 'gov.irs.deductions']); + }); + + test('given more children than a page holds then every page is read', async () => { + const { client, fetchMock } = childrenClient(1); + + const children = await fetchParameterChildren( + 'us', + TEST_POLICYENGINE_VERSION, + 'gov.irs', + client + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(children).toHaveLength(2); + }); +}); diff --git a/app/src/tests/unit/components/flagship/policyBrowser/V3PolicyBrowserV2.test.tsx b/app/src/tests/unit/components/flagship/policyBrowser/V3PolicyBrowserV2.test.tsx new file mode 100644 index 000000000..25b5524cf --- /dev/null +++ b/app/src/tests/unit/components/flagship/policyBrowser/V3PolicyBrowserV2.test.tsx @@ -0,0 +1,162 @@ +import { useState } from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor, within } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import V3PolicyBrowser from '@/components/flagship/policyBrowser/V3PolicyBrowser'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { catalogParametersResult } from '@/tests/fixtures/api/publicApi/catalogParametersMocks'; +import { childrenPage } from '@/tests/fixtures/api/publicApi/parameterChildrenMocks'; +import { + jsonResponse, + requestsTo, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { + CTC_FULLY_REFUNDABLE, + STANDARD_DEDUCTION_JOINT, + STANDARD_DEDUCTION_SINGLE, +} from '@/tests/fixtures/api/publicApi/searchFileMocks'; +import { seedV2ModelCatalog, testQueryClient } from '@/tests/fixtures/hooks/v3MetadataWrapper'; +import { formatValue, getCurrentValue } from '@/utils/parameterValues'; + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +const CHILDREN_PATH = '/v2/parameters/children'; +const PARAMETERS_PATH = '/v2/model-catalog/parameters'; +const ADDABLE = new Set([ + STANDARD_DEDUCTION_SINGLE, + STANDARD_DEDUCTION_JOINT, + CTC_FULLY_REFUNDABLE, +]); + +/** The browser as the add bar holds it: the open folder is the bar's state. */ +function Browser({ onAdd }: { onAdd: (path: string) => void }) { + const [path, setPath] = useState(null); + const valueFor = (parameterPath: string) => { + const value = getCurrentValue(store.getState().metadata.parameters[parameterPath]?.values); + return value === undefined ? null : formatValue(value, 'currency-USD'); + }; + return ( + + ); +} + +describe('V3PolicyBrowser with v3 on the v2 routes', () => { + let fetchStub: ReturnType; + let releaseFirstLevel: () => void; + + beforeEach(async () => { + const firstLevel = new Promise((resolve) => { + releaseFirstLevel = resolve; + }); + fetchStub = stubPublicApiFetch({ + [CHILDREN_PATH]: async (url) => { + const parentPath = url.searchParams.get('parent_path') ?? ''; + if (parentPath === 'gov') { + await firstLevel; + } + return jsonResponse(childrenPage(parentPath)); + }, + [PARAMETERS_PATH]: (url) => + jsonResponse({ + status: 'ok', + message: null, + result: catalogParametersResult(url.searchParams.getAll('name')), + }), + }); + await seedV2ModelCatalog(); + }); + + afterEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + test('given a level loading then placeholders stand for it until it arrives', async () => { + render( + + + + ); + + expect(screen.getByRole('status', { name: 'Loading the policy tree' })).toBeInTheDocument(); + releaseFirstLevel(); + expect(await screen.findByRole('button', { name: 'Open IRS' })).toBeInTheDocument(); + expect(requestsTo(fetchStub, CHILDREN_PATH)).toHaveLength(1); + }); + + test('given folders are opened then each level is one request, with its trail and counts', async () => { + // Given + const user = userEvent.setup(); + releaseFirstLevel(); + render( + + + + ); + + // When + await user.click(await screen.findByRole('button', { name: 'Open IRS' })); + await user.click(await screen.findByRole('button', { name: 'Open Deductions' })); + await user.click(await screen.findByRole('button', { name: 'Open Standard deduction' })); + await user.click(await screen.findByRole('button', { name: 'Open Standard deduction amount' })); + + // Then + const trail = screen.getByRole('navigation', { name: 'Policy tree location' }); + expect( + within(trail) + .getAllByRole('button') + .map((crumb) => crumb.textContent) + ).toEqual(['All programs', 'IRS', 'Deductions', 'Standard deduction']); + expect( + requestsTo(fetchStub, CHILDREN_PATH).map((url) => url.searchParams.get('parent_path')) + ).toEqual([ + 'gov', + 'gov.irs', + 'gov.irs.deductions', + 'gov.irs.deductions.standard', + 'gov.irs.deductions.standard.amount', + ]); + // The folder's parameters, with current law loaded in one request. + expect( + await screen.findByRole('button', { name: 'Add Single to your draft' }) + ).toHaveTextContent('$15,000'); + expect(screen.getByRole('button', { name: 'Add Joint to your draft' })).toHaveTextContent( + '$30,000' + ); + expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(1); + }); + + test('given a parameter is added then the browser hands its path on', async () => { + const user = userEvent.setup(); + const onAdd = vi.fn(); + releaseFirstLevel(); + render( + + + + ); + await user.click(await screen.findByRole('button', { name: 'Open IRS' })); + await user.click(await screen.findByRole('button', { name: 'Open Credits' })); + await user.click(await screen.findByRole('button', { name: 'Open Child Tax Credit' })); + await user.click(await screen.findByRole('button', { name: 'Open Refundable' })); + + await user.click( + await screen.findByRole('button', { name: 'Add Fully refundable to your draft' }) + ); + + await waitFor(() => expect(onAdd).toHaveBeenCalledWith(CTC_FULLY_REFUNDABLE)); + }); +}); From 7b418144f809c8d33e1a5f091d823ec15c7a0792 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:46:31 +0400 Subject: [PATCH 08/28] Save v3's reforms as /v2/policies policies on the v2 routes With `V3_API_ROUTES` at 'v2', a reform saved to the account becomes a `/v2/policies` policy (`POST /v2/policies`, for the loaded catalog version and its tax-benefit model) and its `/v2/user-policies` row points at it with `policy_id`. Each dated value names its parameter's catalog ID, which comes with the parameters loaded on demand; any not loaded yet load first. Editing a reform's parameters makes a new policy and repoints the same row with `PATCH /v2/user-policies/{id}` (`policy_id`), so its ID, name and date stay; a rename only patches the name. Rows saved before the switch that hold only a v1 policy still open, read from `GET /{country}/policy/{id}`. Each v2 policy is fetched once per session. On the v1 routes, and for reforms kept in the browser, nothing changes. Co-Authored-By: Claude Opus 5.5 --- app/src/api/reformStore.ts | 343 +++++++++++++- app/src/hooks/useFlagshipStores.ts | 54 ++- app/src/tests/unit/api/reformStoreV2.test.ts | 419 ++++++++++++++++++ .../unit/hooks/useFlagshipStoresV2.test.tsx | 252 +++++++++++ app/src/tests/unit/types/v2User.test.ts | 23 + app/src/types/v2User.ts | 17 +- 6 files changed, 1087 insertions(+), 21 deletions(-) create mode 100644 app/src/tests/unit/api/reformStoreV2.test.ts create mode 100644 app/src/tests/unit/hooks/useFlagshipStoresV2.test.tsx create mode 100644 app/src/tests/unit/types/v2User.test.ts diff --git a/app/src/api/reformStore.ts b/app/src/api/reformStore.ts index 411c7f572..0930663da 100644 --- a/app/src/api/reformStore.ts +++ b/app/src/api/reformStore.ts @@ -1,5 +1,7 @@ import { convertDateRangeMapToValueIntervals } from '@/adapters/conversionHelpers'; import { fetchPolicyById } from '@/api/policy'; +import { createPublicApiClient, requireResult, type PublicApiClient } from '@/api/publicApi/client'; +import type { components } from '@/api/publicApi/schema'; import { AccountAssociationRoutes, BrowserOnlyIdError, @@ -7,9 +9,12 @@ import { isLegacyId, type ApiStoreContext, } from '@/api/v2Associations'; +import { FOREVER } from '@/constants'; import { createReformPolicy } from '@/libs/flagship/runReport'; import { clampStoreText } from '@/libs/flagship/storeText'; +import { V3_API_ROUTES, type V3ApiRoutes } from '@/libs/flagship/v3ApiRoutes'; import { Reform, ReformBaseline, ReformProvenance } from '@/types/ingredients/Reform'; +import type { V2CatalogReference } from '@/types/metadata'; import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; import type { PolicyMetadataParams } from '@/types/metadata/policyMetadata'; import { Parameter } from '@/types/subIngredients/parameter'; @@ -20,16 +25,21 @@ import type { UserPolicyItem, UserPolicyPatchBody, } from '@/types/v2User'; +import { coercePolicyParameterValue, resolvePolicyParameterValueSpec } from '@/utils/valueCoercion'; /** * Store for the flagship app's reform objects. * - * Either way, saving a reform materializes its parameters as a v1 policy - * (`POST /{country}/policy`, which dedupes identical ones), and a save - * that cannot create the policy fails. Signed out (and outside the US and - * UK) the reform, with its policy ID, lives in localStorage. Signed in, it - * is a row in the account's `/v2/user-policies` carrying the policy ID, - * the name, the baseline, and the provenance. + * Saving a reform first makes its parameters a policy, and a save that + * cannot create the policy fails. Where saved work is in the browser + * (sign-in not configured, or outside the US and UK) the reform, with the + * ID of its v1 policy (`POST /{country}/policy`), lives in localStorage. + * Where it is in the account, signed in or signed out, the reform is a row + * in `/v2/user-policies` carrying the name, the baseline, the provenance + * and the policy: a v1 policy (`legacy_policy_id`, ApiReformStore) before + * the switch, and a `/v2/policies` policy (`policy_id`, ApiV2ReformStore) + * after it. V3_API_ROUTES picks the account store. Both APIs deduplicate + * identical policies. */ export type NewReform = Omit; export type ReformUpdates = Partial>; @@ -403,10 +413,325 @@ export class ApiReformStore implements ReformStore { } } -/** The reform store for a session: the account's when given one, else localStorage. */ +/* Saved reforms on the v2 routes (V3_API_ROUTES 'v2'). */ + +type V2PolicyItem = components['schemas']['PolicyItem']; +type V2PolicyCreateBody = components['schemas']['PolicyCreateRequest']; + +/** A v2 policy's JSON value: infinities as the API spells them (JSON has none). */ +function v2PolicyValue(value: unknown): unknown { + if (value === Infinity) { + return 'Infinity'; + } + if (value === -Infinity) { + return '-Infinity'; + } + return value; +} + +/** A date as the UTC midnight the v2 API stores (v1 periods are whole days). */ +function utcMidnight(date: string): string { + return `${date}T00:00:00Z`; +} + +/** + * The `POST /v2/policies` body for a reform's parameters: each dated value + * names its parameter's catalog UUID (`parameterIds`, from the parameters + * loaded on demand), and is typed as a v1 run types it when the + * parameter's metadata is loaded. Dates keep v1's whole-day periods + * (including its 2100-12-31 "forever"), as the dual-write copies of v1 + * policies do, so identical changes deduplicate to one v2 policy. + */ +export function v2ReformPolicyBody( + countryId: string, + parameters: Parameter[], + catalog: V2CatalogReference, + parameterIds: Record, + metadata?: ParameterMetadataCollection +): V2PolicyCreateBody { + return { + country_id: countryId as V2PolicyCreateBody['country_id'], + tax_benefit_model_id: catalog.taxBenefitModelId, + parameter_values: parameters.flatMap((parameter) => { + const parameterId = parameterIds[parameter.name]; + if (!parameterId) { + throw new Error( + `${parameter.name} is not a parameter of the PolicyEngine model ${catalog.policyengineVersion}` + ); + } + const valueSpec = + metadata?.[parameter.name]?.type === 'parameter' + ? resolvePolicyParameterValueSpec(parameter.name, metadata) + : null; + return parameter.values.map((interval) => ({ + parameter_id: parameterId, + value: v2PolicyValue( + valueSpec + ? coercePolicyParameterValue(interval.value, valueSpec, parameter.name) + : interval.value + ) as V2PolicyCreateBody['parameter_values'][number]['value'], + start_date: utcMidnight(interval.startDate), + end_date: utcMidnight(interval.endDate), + })); + }), + }; +} + +/** Creates (or, as the API deduplicates, finds) a reform's v2 policy; returns its ID. */ +export type CreateV2ReformPolicy = ( + countryId: string, + parameters: Parameter[], + catalog: V2CatalogReference, + parameterIds: Record, + metadata?: ParameterMetadataCollection +) => Promise; + +export async function createV2ReformPolicy( + countryId: string, + parameters: Parameter[], + catalog: V2CatalogReference, + parameterIds: Record, + metadata?: ParameterMetadataCollection, + client: PublicApiClient = createPublicApiClient() +): Promise { + const body = v2ReformPolicyBody(countryId, parameters, catalog, parameterIds, metadata); + const result = await requireResult( + client.POST('/v2/policies', { + params: { + query: { + country_id: body.country_id, + policyengine_version: catalog.policyengineVersion, + }, + }, + body, + }) + ); + return result.item.id; +} + +/** A v2 policy. Policies never change, so callers may cache them. */ +export async function fetchV2PolicyItem( + countryId: string, + policyId: string, + client: PublicApiClient = createPublicApiClient() +): Promise { + const result = await requireResult( + client.GET('/v2/policies/{policy_id}', { + params: { + path: { policy_id: policyId }, + query: { country_id: countryId as V2PolicyCreateBody['country_id'] }, + }, + }) + ); + return result.item; +} + +/** A v2 policy's values as reform parameters, each oldest first; an open end is v1's forever. */ +export function parametersFromV2Policy( + policy: Pick +): Parameter[] { + const byName = new Map(); + for (const parameterValue of policy.parameter_values) { + const intervals = byName.get(parameterValue.parameter_name) ?? []; + intervals.push({ + startDate: parameterValue.start_date.slice(0, 10), + endDate: parameterValue.end_date ? parameterValue.end_date.slice(0, 10) : FOREVER, + value: parameterValue.value as ValueInterval['value'], + }); + byName.set(parameterValue.parameter_name, intervals); + } + return [...byName].map(([name, values]) => ({ + name, + values: values.sort((first, second) => first.startDate.localeCompare(second.startDate)), + })); +} + +export interface ApiV2ReformStoreOptions extends ApiReformStoreOptions { + /** The loaded v2 catalog version: its PolicyEngine.py version and model ID. */ + getV2Catalog?: () => V2CatalogReference | null | undefined; + /** + * The catalog IDs of the named parameters, loading any not loaded yet + * (they come with each parameter loaded on demand); names the catalog + * version does not have are left out. + */ + resolveParameterIds?: ( + countryId: string, + names: readonly string[] + ) => Promise>; + createV2Policy?: CreateV2ReformPolicy; + /** Reads a v2 policy. */ + fetchV2Policy?: (countryId: string, policyId: string) => Promise; +} + +/** The model catalog must be loaded from the v2 routes before a v2 policy can be made. */ +export class V2CatalogUnavailableError extends Error { + constructor() { + super('The PolicyEngine model has not loaded yet, so this reform cannot be saved.'); + this.name = 'V2CatalogUnavailableError'; + } +} + +/** Whether an account row is a v3 reform: reforms always carry provenance. */ +function isV3Reform(item: UserPolicyItem): boolean { + return item.provenance !== null && (item.policy_id !== null || isLegacyId(item.legacy_policy_id)); +} + +/** + * Reforms in the account (`/v2/user-policies`) on v2 policies. A save makes + * the parameters a `/v2/policies` policy and the row points at it with + * `policy_id`; parameter edits make a new policy and repoint the row (the + * API then clears any `legacy_policy_id`); a rename only patches the name. + * Rows saved before the switch that hold only a v1 policy + * (`legacy_policy_id`) still open: their parameters are read from + * `GET /{country}/policy/{id}` until the one-time migration links them to + * v2 policies. + */ +export class ApiV2ReformStore implements ReformStore { + private readonly routes: AccountAssociationRoutes; + private readonly getParameterMetadata: NonNullable; + private readonly getV2Catalog: NonNullable; + private readonly resolveParameterIds: NonNullable; + private readonly createV2Policy: CreateV2ReformPolicy; + private readonly fetchV2Policy: NonNullable; + private readonly fetchPolicyJson: NonNullable; + + constructor( + private readonly context: ApiStoreContext, + options: ApiV2ReformStoreOptions = {} + ) { + this.routes = new AccountAssociationRoutes(context, 'user-policies', 'Reform'); + this.getParameterMetadata = options.getParameterMetadata ?? (() => null); + this.getV2Catalog = options.getV2Catalog ?? (() => null); + this.resolveParameterIds = options.resolveParameterIds ?? (async () => ({})); + this.createV2Policy = options.createV2Policy ?? createV2ReformPolicy; + this.fetchV2Policy = options.fetchV2Policy ?? fetchV2PolicyItem; + this.fetchPolicyJson = options.fetchPolicyJson ?? fetchV1PolicyJson; + } + + /** The v2 policy for a set of parameters, created for the loaded catalog version. */ + private async materialize(countryId: string, parameters: Parameter[]): Promise { + const catalog = this.getV2Catalog(); + if (!catalog) { + throw new V2CatalogUnavailableError(); + } + const parameterIds = await this.resolveParameterIds( + countryId, + parameters.map((parameter) => parameter.name) + ); + return this.createV2Policy( + countryId, + parameters, + catalog, + parameterIds, + this.getParameterMetadata() ?? undefined + ); + } + + async create(reform: NewReform): Promise { + const countryId = this.routes.writeCountry(reform.countryId); + const policyId = isAccountId(reform.policyId) + ? reform.policyId + : await this.materialize(countryId, reform.parameters); + const item = await this.routes.create({ + country_id: countryId, + policy_id: policyId, + ...(reform.label ? { name: clampStoreText(reform.label) } : {}), + baseline: reform.baseline, + provenance: provenanceToWire(reform.provenance), + }); + return this.reformFromItem(item, reform.parameters); + } + + async findMine(countryId?: string): Promise { + if (countryId && countryId !== this.context.countryId) { + return []; + } + const items = (await this.routes.list()).filter(isV3Reform); + const loaded = await Promise.allSettled(items.map((item) => this.toReform(item))); + return byRecentUpdate( + loaded.map((outcome, index) => + outcome.status === 'fulfilled' + ? outcome.value + : { ...this.reformFromItem(items[index], []), parametersUnavailable: true } + ) + ); + } + + async findById(reformId: string): Promise { + const item = await this.routes.get(reformId); + return item && isV3Reform(item) ? this.toReform(item) : null; + } + + async update(reformId: string, updates: ReformUpdates): Promise { + // Refuse a browser ID before creating a policy for it. + if (!isAccountId(reformId)) { + throw new BrowserOnlyIdError(); + } + const body: UserPolicyPatchBody = {}; + if (updates.label !== undefined) { + body.name = clampStoreText(updates.label) || null; + } + if (updates.baseline !== undefined) { + body.baseline = updates.baseline; + } + if (updates.provenance !== undefined) { + body.provenance = provenanceToWire(updates.provenance); + } + if (updates.parameters !== undefined) { + body.policy_id = await this.materialize(this.context.countryId, updates.parameters); + } else if (isAccountId(updates.policyId)) { + body.policy_id = updates.policyId; + } + const item = await this.routes.patch(reformId, body); + return updates.parameters !== undefined + ? this.reformFromItem(item, updates.parameters) + : this.toReform(item); + } + + delete(reformId: string): Promise { + return this.routes.delete(reformId); + } + + private async toReform(item: UserPolicyItem): Promise { + const parameters = item.policy_id + ? parametersFromV2Policy(await this.fetchV2Policy(item.country_id, item.policy_id)) + : parametersFromPolicyJson( + await this.fetchPolicyJson(item.country_id, String(item.legacy_policy_id)), + this.getParameterMetadata() + ); + return this.reformFromItem(item, parameters); + } + + private reformFromItem(item: UserPolicyItem, parameters: Parameter[]): Reform { + return { + id: item.id, + countryId: item.country_id, + label: item.name ?? undefined, + policyId: item.policy_id ?? String(item.legacy_policy_id), + parameters, + baseline: (item.baseline ?? 'current-law') as ReformBaseline, + provenance: provenanceFromWire(item.provenance), + createdAt: item.created_at, + updatedAt: item.updated_at, + }; + } +} + +/** + * The reform store for a session: the account's when given one, else + * localStorage. With an account, `routes` (V3_API_ROUTES unless a caller + * says otherwise) picks v1 or v2 policies; reforms kept in this browser + * make v1 policies either way. + */ export function getReformStore( account?: ApiStoreContext | null, - options?: ApiReformStoreOptions + options?: ApiV2ReformStoreOptions, + routes: V3ApiRoutes = V3_API_ROUTES ): ReformStore { - return account ? new ApiReformStore(account, options) : new LocalStorageReformStore(options); + if (!account) { + return new LocalStorageReformStore(options); + } + return routes === 'v2' + ? new ApiV2ReformStore(account, options) + : new ApiReformStore(account, options); } diff --git a/app/src/hooks/useFlagshipStores.ts b/app/src/hooks/useFlagshipStores.ts index fecc89b22..b3419d610 100644 --- a/app/src/hooks/useFlagshipStores.ts +++ b/app/src/hooks/useFlagshipStores.ts @@ -1,6 +1,6 @@ import { useMemo, useRef } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useSelector } from 'react-redux'; +import { useSelector, useStore } from 'react-redux'; import { findReportRecordByLink, getFlagshipReportStore, @@ -8,11 +8,17 @@ import { type FlagshipReportRecord, type FlagshipReportStore, } from '@/api/flagshipReportStore'; -import { fetchV1PolicyJson, getReformStore, type ReformStore } from '@/api/reformStore'; +import { + fetchV1PolicyJson, + fetchV2PolicyItem, + getReformStore, + type ReformStore, +} from '@/api/reformStore'; import { fetchSharedReport } from '@/api/sharedReport'; import { isAccountId, type ApiStoreContext } from '@/api/v2Associations'; import { useCountryContext } from '@/contexts/CountryContext'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useLoadV3Parameters } from '@/hooks/useV3Parameters'; import { useAuthStatus } from '@/libs/auth/AuthContext'; import { getBrowserUserId } from '@/libs/auth/browserUser'; import { useV2UserClient } from '@/libs/auth/useV2UserClient'; @@ -65,32 +71,64 @@ export function useV3SavedWork(): V3SavedWork { const v1PolicyJsonKey = (countryId: string, policyId: string) => ['v1-policy-json', countryId, policyId] as const; -/** How long a fetched v1 policy stays cached without a reader. */ -const V1_POLICY_JSON_GC_TIME = 30 * 60 * 1000; +/** A v2 policy (`GET /v2/policies/{id}`), per country. */ +const v2PolicyKey = (countryId: string, policyId: string) => + ['v2-policy', countryId, policyId] as const; + +/** How long a fetched policy (v1 or v2) stays cached without a reader. */ +const SAVED_POLICY_GC_TIME = 30 * 60 * 1000; +/** + * The reform store for this session. Which policies an account's reforms + * point at (v1 or `/v2/policies`) follows V3_API_ROUTES (see + * libs/flagship/v3ApiRoutes); on v2, the catalog IDs a policy names come + * with the parameters loaded on demand. + */ export function useReformStore(): ReformStore { const { account } = useV3SavedWork(); const parameters = useSelector((state: RootState) => state.metadata?.parameters); - // Read through a ref so metadata arriving later does not rebuild the store. + const v2Catalog = useSelector((state: RootState) => state.metadata?.v2Catalog); + // Read through refs so metadata arriving later does not rebuild the store. const parametersRef = useRef(parameters); parametersRef.current = parameters; + const v2CatalogRef = useRef(v2Catalog); + v2CatalogRef.current = v2Catalog; + const loadParameters = useLoadV3Parameters(); + const loadParametersRef = useRef(loadParameters); + loadParametersRef.current = loadParameters; + const reduxStore = useStore(); const queryClient = useQueryClient(); return useMemo( () => getReformStore(account, { getParameterMetadata: () => parametersRef.current, - // v1 policies never change, so each is fetched once per session + getV2Catalog: () => v2CatalogRef.current, + resolveParameterIds: async (_countryId, names) => { + await loadParametersRef.current(names); + const ids = reduxStore.getState().metadata.v2ParameterIds ?? {}; + return Object.fromEntries( + names.filter((name) => ids[name]).map((name) => [name, ids[name]]) + ); + }, + // Policies never change, so each is fetched once per session // rather than on every list of the account's reforms. fetchPolicyJson: (countryId, policyId) => queryClient.fetchQuery({ queryKey: v1PolicyJsonKey(countryId, policyId), queryFn: () => fetchV1PolicyJson(countryId, policyId), staleTime: Infinity, - gcTime: V1_POLICY_JSON_GC_TIME, + gcTime: SAVED_POLICY_GC_TIME, + }), + fetchV2Policy: (countryId, policyId) => + queryClient.fetchQuery({ + queryKey: v2PolicyKey(countryId, policyId), + queryFn: () => fetchV2PolicyItem(countryId, policyId), + staleTime: Infinity, + gcTime: SAVED_POLICY_GC_TIME, }), }), - [account, queryClient] + [account, queryClient, reduxStore] ); } diff --git a/app/src/tests/unit/api/reformStoreV2.test.ts b/app/src/tests/unit/api/reformStoreV2.test.ts new file mode 100644 index 000000000..79261db9b --- /dev/null +++ b/app/src/tests/unit/api/reformStoreV2.test.ts @@ -0,0 +1,419 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPublicApiClient } from '@/api/publicApi/client'; +import { + ApiReformStore, + ApiV2ReformStore, + createV2ReformPolicy, + getReformStore, + LocalStorageReformStore, + parametersFromV2Policy, +} from '@/api/reformStore'; +import { FOREVER } from '@/constants'; +import { + TEST_TAX_BENEFIT_MODEL_ID as V2_MODEL_ID, + TEST_POLICYENGINE_VERSION as V2_POLICYENGINE_VERSION, +} from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; +import { + ASSOCIATION_IDS, + createTestApiContext, + fetchCall, + itemResponse, + noContentResponse, + okResponse, + pageResponse, + policyItem, + TEST_API_BASE, +} from '@/tests/fixtures/api/v2UserMocks'; +import type { Reform } from '@/types/ingredients/Reform'; +import type { V2CatalogReference } from '@/types/metadata'; + +const CTC_AMOUNT = 'gov.irs.credits.ctc.amount.base[0].amount'; +const CTC_PHASE_OUT = 'gov.irs.credits.ctc.phase_out.threshold'; + +const V2_POLICY_ID = 'a1b2c3d4-0000-4000-8000-000000000001'; +const REPLACEMENT_V2_POLICY_ID = 'a1b2c3d4-0000-4000-8000-000000000002'; +const LEGACY_ONLY_POLICY_ID = 95762; + +const V2_PARAMETER_IDS = { + CTC_AMOUNT: 'c0000000-0000-4000-8000-000000000001', + CTC_PHASE_OUT: 'c0000000-0000-4000-8000-000000000002', +}; + +const CATALOG: V2CatalogReference = { + policyengineVersion: V2_POLICYENGINE_VERSION, + taxBenefitModelId: V2_MODEL_ID, +}; + +/** Each parameter's catalog ID, as the on-demand loads record them. */ +const PARAMETER_IDS: Record = { + [CTC_AMOUNT]: V2_PARAMETER_IDS.CTC_AMOUNT, + [CTC_PHASE_OUT]: V2_PARAMETER_IDS.CTC_PHASE_OUT, +}; + +/** Resolves catalog IDs as the store's caller does: loading them, then reading them. */ +const resolveParameterIds = vi.fn(async (_countryId: string, names: readonly string[]) => + Object.fromEntries( + names.filter((name) => PARAMETER_IDS[name]).map((name) => [name, PARAMETER_IDS[name]]) + ) +); + +const REFORM_INPUT: Omit = { + countryId: 'us', + label: 'CTC to $3,600', + parameters: [ + { + name: CTC_AMOUNT, + values: [{ startDate: '2026-01-01', endDate: FOREVER, value: 3600 }], + }, + ], + baseline: 'current-law', + provenance: { source: 'manual' }, +}; + +/** The v2 policy as GET /v2/policies/{id} returns it. */ +function v2Policy(policyId = V2_POLICY_ID) { + return { + id: policyId, + country_id: 'us', + tax_benefit_model_id: V2_MODEL_ID, + created_at: '2026-10-20T10:00:00Z', + updated_at: '2026-10-20T10:00:00Z', + parameter_values: [ + { + id: 'b0000000-0000-4000-8000-000000000001', + parameter_id: V2_PARAMETER_IDS.CTC_AMOUNT, + parameter_name: CTC_AMOUNT, + value: 3600, + start_date: '2026-01-01T00:00:00Z', + end_date: '2100-12-31T00:00:00Z', + }, + { + id: 'b0000000-0000-4000-8000-000000000002', + parameter_id: V2_PARAMETER_IDS.CTC_PHASE_OUT, + parameter_name: CTC_PHASE_OUT, + value: 'Infinity', + start_date: '2027-01-01T00:00:00Z', + end_date: null, + }, + { + id: 'b0000000-0000-4000-8000-000000000003', + parameter_id: V2_PARAMETER_IDS.CTC_PHASE_OUT, + parameter_name: CTC_PHASE_OUT, + value: 400000, + start_date: '2026-01-01T00:00:00Z', + end_date: '2026-12-31T00:00:00Z', + }, + ], + }; +} + +const V2_REFORM_ROW = policyItem({ + id: ASSOCIATION_IDS.REFORM, + policy_id: V2_POLICY_ID, + legacy_policy_id: null, + name: 'CTC to $3,600', + baseline: 'current-law', + provenance: { source: 'manual', ref: null, confidence: null }, +}); + +function v2Store(catalog: V2CatalogReference | null = CATALOG) { + const { fetchMock, context } = createTestApiContext('us'); + const createPolicy = vi.fn().mockResolvedValue(V2_POLICY_ID); + const fetchV2Policy = vi.fn().mockResolvedValue(v2Policy()); + const fetchPolicyJson = vi.fn().mockResolvedValue({ + [CTC_AMOUNT]: { '2026-01-01.2100-12-31': 3000 }, + }); + const store = new ApiV2ReformStore(context, { + getV2Catalog: () => catalog, + resolveParameterIds, + createV2Policy: createPolicy, + fetchV2Policy, + fetchPolicyJson, + }); + return { store, fetchMock, createPolicy, fetchV2Policy, fetchPolicyJson }; +} + +describe('createV2ReformPolicy', () => { + it('given parameters then POST /v2/policies names catalog UUIDs at UTC midnight', async () => { + // Given + const fetchMock = vi.fn().mockResolvedValue(okResponse({ item: v2Policy() }, 201)); + const client = createPublicApiClient({ + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const parameters = [ + ...REFORM_INPUT.parameters, + { + name: CTC_PHASE_OUT, + values: [{ startDate: '2027-01-01', endDate: FOREVER, value: Infinity }], + }, + ]; + + // When + const policyId = await createV2ReformPolicy( + 'us', + parameters, + CATALOG, + PARAMETER_IDS, + undefined, + client + ); + + // Then + expect(policyId).toBe(V2_POLICY_ID); + const request = fetchMock.mock.calls[0][0] as Request; + const url = new URL(request.url); + expect(request.method).toBe('POST'); + expect(url.pathname).toBe('/v2/policies'); + expect(url.searchParams.get('country_id')).toBe('us'); + expect(url.searchParams.get('policyengine_version')).toBe(V2_POLICYENGINE_VERSION); + expect(await request.json()).toEqual({ + country_id: 'us', + tax_benefit_model_id: V2_MODEL_ID, + parameter_values: [ + { + parameter_id: V2_PARAMETER_IDS.CTC_AMOUNT, + value: 3600, + start_date: '2026-01-01T00:00:00Z', + end_date: '2100-12-31T00:00:00Z', + }, + { + parameter_id: V2_PARAMETER_IDS.CTC_PHASE_OUT, + value: 'Infinity', + start_date: '2027-01-01T00:00:00Z', + end_date: '2100-12-31T00:00:00Z', + }, + ], + }); + }); + + it('given a parameter the catalog lacks then nothing is sent', async () => { + // Given + const fetchMock = vi.fn(); + const client = createPublicApiClient({ + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + // When + const creation = createV2ReformPolicy( + 'us', + [{ name: 'gov.contrib.unlisted', values: REFORM_INPUT.parameters[0].values }], + CATALOG, + PARAMETER_IDS, + undefined, + client + ); + + // Then + await expect(creation).rejects.toThrow('gov.contrib.unlisted'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('parametersFromV2Policy', () => { + it('given a v2 policy then each parameter lists its dated values, oldest first', () => { + expect(parametersFromV2Policy(v2Policy())).toEqual([ + { + name: CTC_AMOUNT, + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 3600 }], + }, + { + name: CTC_PHASE_OUT, + values: [ + { startDate: '2026-01-01', endDate: '2026-12-31', value: 400000 }, + { startDate: '2027-01-01', endDate: FOREVER, value: 'Infinity' }, + ], + }, + ]); + }); +}); + +describe('ApiV2ReformStore', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('given a reform then its parameters become a v2 policy and the row links policy_id', async () => { + // Given + const { store, fetchMock, createPolicy } = v2Store(); + fetchMock.mockResolvedValue(itemResponse(V2_REFORM_ROW, 201)); + + // When + const created = await store.create(REFORM_INPUT); + + // Then + expect(resolveParameterIds).toHaveBeenCalledWith('us', [CTC_AMOUNT]); + expect(createPolicy).toHaveBeenCalledWith( + 'us', + REFORM_INPUT.parameters, + CATALOG, + { [CTC_AMOUNT]: V2_PARAMETER_IDS.CTC_AMOUNT }, + undefined + ); + const call = fetchCall(fetchMock); + expect(call.path).toBe('/v2/user-policies'); + expect(call.body).toEqual({ + country_id: 'us', + policy_id: V2_POLICY_ID, + name: 'CTC to $3,600', + baseline: 'current-law', + provenance: { source: 'manual', ref: null, confidence: null }, + }); + expect(created).toMatchObject({ + id: ASSOCIATION_IDS.REFORM, + policyId: V2_POLICY_ID, + parameters: REFORM_INPUT.parameters, + }); + }); + + it('given a reform already on a v2 policy then no new policy is created', async () => { + // Given + const { store, fetchMock, createPolicy } = v2Store(); + fetchMock.mockResolvedValue(itemResponse(V2_REFORM_ROW, 201)); + + // When + await store.create({ ...REFORM_INPUT, policyId: V2_POLICY_ID }); + + // Then + expect(createPolicy).not.toHaveBeenCalled(); + expect(fetchCall(fetchMock).body.policy_id).toBe(V2_POLICY_ID); + }); + + it('given no v2 catalog loaded then saving fails before any request', async () => { + // Given + const { store, fetchMock, createPolicy } = v2Store(null); + + // When + const creation = store.create(REFORM_INPUT); + + // Then + await expect(creation).rejects.toThrow('model'); + expect(createPolicy).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('given saved rows then v2 rows read their policy and v1-only rows read the v1 policy', async () => { + // Given + const { store, fetchMock, fetchV2Policy, fetchPolicyJson } = v2Store(); + const legacyRow = policyItem({ + id: '66666666-6666-4666-8666-666666666666', + policy_id: null, + legacy_policy_id: LEGACY_ONLY_POLICY_ID, + provenance: { source: 'bill', ref: 'HR-1', confidence: null }, + updated_at: '2026-10-01T00:00:00+00:00', + }); + fetchMock.mockResolvedValue( + pageResponse([ + V2_REFORM_ROW, + legacyRow, + policyItem({ id: 'legacy-builder', provenance: null }), + ]) + ); + + // When + const reforms = await store.findMine('us'); + + // Then + expect(fetchV2Policy).toHaveBeenCalledWith('us', V2_POLICY_ID); + expect(fetchPolicyJson).toHaveBeenCalledWith('us', String(LEGACY_ONLY_POLICY_ID)); + expect(reforms.map((reform) => [reform.id, reform.policyId])).toEqual([ + [ASSOCIATION_IDS.REFORM, V2_POLICY_ID], + [legacyRow.id, String(LEGACY_ONLY_POLICY_ID)], + ]); + expect(reforms[0].parameters).toEqual(parametersFromV2Policy(v2Policy())); + expect(reforms[1].parameters).toEqual([ + { + name: CTC_AMOUNT, + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 3000 }], + }, + ]); + }); + + it('given a row whose policy fails to load then it is listed without its changes', async () => { + // Given + const { store, fetchMock, fetchV2Policy } = v2Store(); + fetchV2Policy.mockRejectedValue(new Error('Policy not found')); + fetchMock.mockResolvedValue(pageResponse([V2_REFORM_ROW])); + + // When + const [reform] = await store.findMine('us'); + + // Then + expect(reform.parametersUnavailable).toBe(true); + expect(reform.parameters).toEqual([]); + }); + + it('given changed parameters then a new v2 policy is created and the row is repointed', async () => { + // Given + const { store, fetchMock, createPolicy } = v2Store(); + createPolicy.mockResolvedValue(REPLACEMENT_V2_POLICY_ID); + fetchMock.mockResolvedValue( + itemResponse({ ...V2_REFORM_ROW, policy_id: REPLACEMENT_V2_POLICY_ID }) + ); + const changed = [ + { name: CTC_AMOUNT, values: [{ startDate: '2026-01-01', endDate: FOREVER, value: 4000 }] }, + ]; + + // When + const updated = await store.update(ASSOCIATION_IDS.REFORM, { + parameters: changed, + label: 'CTC to $4,000', + }); + + // Then + const call = fetchCall(fetchMock); + expect(call.method).toBe('PATCH'); + expect(call.path).toBe(`/v2/user-policies/${ASSOCIATION_IDS.REFORM}`); + expect(call.body).toEqual({ name: 'CTC to $4,000', policy_id: REPLACEMENT_V2_POLICY_ID }); + expect(updated.policyId).toBe(REPLACEMENT_V2_POLICY_ID); + expect(updated.parameters).toEqual(changed); + }); + + it('given only a new name then only the name is patched', async () => { + // Given + const { store, fetchMock, createPolicy } = v2Store(); + fetchMock.mockResolvedValue(itemResponse({ ...V2_REFORM_ROW, name: 'Renamed' })); + + // When + await store.update(ASSOCIATION_IDS.REFORM, { label: 'Renamed' }); + + // Then + expect(createPolicy).not.toHaveBeenCalled(); + expect(fetchCall(fetchMock).body).toEqual({ name: 'Renamed' }); + }); + + it('given a reform then deleting it removes only the row', async () => { + // Given + const { store, fetchMock } = v2Store(); + fetchMock.mockResolvedValue(noContentResponse()); + + // When + await store.delete(ASSOCIATION_IDS.REFORM); + + // Then + const call = fetchCall(fetchMock); + expect(call.method).toBe('DELETE'); + expect(call.path).toBe(`/v2/user-policies/${ASSOCIATION_IDS.REFORM}`); + }); +}); + +describe('getReformStore', () => { + it('given an account then the routes decide between the v1 and v2 stores', () => { + const { context } = createTestApiContext('us'); + + expect(getReformStore(context, {}, 'v1')).toBeInstanceOf(ApiReformStore); + expect(getReformStore(context, {}, 'v2')).toBeInstanceOf(ApiV2ReformStore); + }); + + it('given no account then reforms stay in this browser on either routes', () => { + expect(getReformStore(null, {}, 'v1')).toBeInstanceOf(LocalStorageReformStore); + expect(getReformStore(null, {}, 'v2')).toBeInstanceOf(LocalStorageReformStore); + }); + + it('given no routes then the store follows V3_API_ROUTES, which is v1 before the switch', () => { + const { context } = createTestApiContext('us'); + + expect(getReformStore(context)).toBeInstanceOf(ApiReformStore); + }); +}); diff --git a/app/src/tests/unit/hooks/useFlagshipStoresV2.test.tsx b/app/src/tests/unit/hooks/useFlagshipStoresV2.test.tsx new file mode 100644 index 000000000..a8c67ee6b --- /dev/null +++ b/app/src/tests/unit/hooks/useFlagshipStoresV2.test.tsx @@ -0,0 +1,252 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react'; +import { Provider as ReduxProvider } from 'react-redux'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { ApiV2ReformStore, LocalStorageReformStore } from '@/api/reformStore'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { useReformStore } from '@/hooks/useFlagshipStores'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import { fetchMetadataThunk } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { catalogParametersResult } from '@/tests/fixtures/api/publicApi/catalogParametersMocks'; +import { + TEST_TAX_BENEFIT_MODEL_ID as V2_MODEL_ID, + TEST_POLICYENGINE_VERSION as V2_POLICYENGINE_VERSION, +} from '@/tests/fixtures/api/publicApi/modelCatalogMocks'; +import { + ASSOCIATION_IDS, + itemResponse, + okResponse, + pageResponse, + policyItem, +} from '@/tests/fixtures/api/v2UserMocks'; +import { + AuthTestProvider, + makeAuthValue, + signedInAuthValue, +} from '@/tests/fixtures/libs/auth/authMocks'; + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +/** A parameter the catalog fixture has, with its catalog ID. */ +const CTC_AMOUNT = 'gov.irs.deductions.standard.amount.SINGLE'; +const V2_PARAMETER_IDS = { CTC_AMOUNT: catalogParametersResult([CTC_AMOUNT]).parameters[0].id }; +const V2_POLICY_ID = 'a1b2c3d4-0000-4000-8000-000000000001'; + +const V2_REFORM_ROW = policyItem({ + id: ASSOCIATION_IDS.REFORM, + policy_id: V2_POLICY_ID, + legacy_policy_id: null, + name: 'CTC to $3,600', + baseline: 'current-law', + provenance: { source: 'manual', ref: null, confidence: null }, +}); + +/** The reform's v2 policy, as `/v2/policies` returns it. */ +const V2_POLICY_ITEM = { + id: V2_POLICY_ID, + country_id: 'us', + tax_benefit_model_id: V2_MODEL_ID, + created_at: '2026-10-20T10:00:00Z', + updated_at: '2026-10-20T10:00:00Z', + parameter_values: [ + { + id: 'b0000000-0000-4000-8000-000000000001', + parameter_id: V2_PARAMETER_IDS.CTC_AMOUNT, + parameter_name: CTC_AMOUNT, + value: 3600, + start_date: '2026-01-01T00:00:00Z', + end_date: '2100-12-31T00:00:00Z', + }, + ], +}; + +const fetchMock = vi.fn(); + +function requestUrl(input: unknown): URL { + return new URL(input instanceof Request ? input.url : String(input)); +} + +function requestMethod(input: unknown, init?: RequestInit): string { + return input instanceof Request ? input.method : (init?.method ?? 'GET'); +} + +/** Answers the account's reform list and saves, and the reform's v2 policy. */ +function respond(input: unknown, init?: RequestInit): Response { + const { pathname } = requestUrl(input); + const method = requestMethod(input, init); + if (method === 'POST' && pathname === '/v2/policies') { + return okResponse({ item: V2_POLICY_ITEM }, 201); + } + if (method === 'POST' && pathname === '/v2/user-policies') { + return itemResponse(V2_REFORM_ROW, 201); + } + if (pathname === `/v2/policies/${V2_POLICY_ID}`) { + return okResponse({ item: V2_POLICY_ITEM }); + } + if (pathname === '/v2/model-catalog/parameters') { + return okResponse(catalogParametersResult(requestUrl(input).searchParams.getAll('name'))); + } + return pageResponse([V2_REFORM_ROW]); +} + +/** The `POST /v2/policies` request a save sent. */ +function policyCreationRequest(): Request | undefined { + return fetchMock.mock.calls + .map(([input]) => input) + .find( + (input): input is Request => + input instanceof Request && + input.method === 'POST' && + requestUrl(input).pathname === '/v2/policies' + ); +} + +function v2PolicyRequests(): number { + return fetchMock.mock.calls.filter( + ([input]) => requestUrl(input).pathname === `/v2/policies/${V2_POLICY_ID}` + ).length; +} + +/** Metadata loaded from the v2 routes, with its catalog identities. */ +function seedV2Metadata() { + const meta = { + arg: { countryId: 'us', routes: 'v2' }, + requestId: 'v2-store-test', + requestStatus: 'pending', + }; + store.dispatch({ type: fetchMetadataThunk.pending.type, meta }); + store.dispatch({ + type: fetchMetadataThunk.fulfilled.type, + meta: { ...meta, requestStatus: 'fulfilled' }, + payload: { + country: 'us', + data: { + status: 'ok', + message: null, + result: { + parameters: {}, + variables: {}, + entities: {}, + variableModules: {}, + economy_options: { region: [], time_period: [], datasets: [] }, + current_law_id: 2, + basicInputs: [], + modelled_policies: { core: {}, filtered: {} }, + version: V2_POLICYENGINE_VERSION, + v2Catalog: { + policyengineVersion: V2_POLICYENGINE_VERSION, + taxBenefitModelId: V2_MODEL_ID, + }, + }, + }, + }, + }); +} + +function wrapperFor(auth: AuthContextValue, countryId: 'us' | 'ca' = 'us') { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ({ children }: { children: React.ReactNode }) => ( + + + + {children} + + + + ); +} + +beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockImplementation(async (input: unknown, init?: RequestInit) => respond(input, init)); + vi.stubGlobal('fetch', fetchMock); + seedV2Metadata(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('useReformStore with v3 on the v2 routes', () => { + test.each([ + ['signed in', signedInAuthValue()], + ['signed out', makeAuthValue()], + ] as const)('given %s in the US then reforms use v2 policies', (_, auth) => { + // When + const reforms = renderHook(() => useReformStore(), { wrapper: wrapperFor(auth) }).result + .current; + + // Then + expect(reforms).toBeInstanceOf(ApiV2ReformStore); + }); + + test('given a country without accounts then reforms stay in this browser', () => { + // When + const reforms = renderHook(() => useReformStore(), { + wrapper: wrapperFor(signedInAuthValue(), 'ca'), + }).result.current; + + // Then + expect(reforms).toBeInstanceOf(LocalStorageReformStore); + }); + + test('given v2 metadata then a saved reform loads its catalog IDs and makes its policy', async () => { + // Given + const reforms = renderHook(() => useReformStore(), { + wrapper: wrapperFor(signedInAuthValue()), + }).result.current; + + // When + const saved = await reforms.create({ + countryId: 'us', + label: 'CTC to $3,600', + parameters: [ + { + name: CTC_AMOUNT, + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 3600 }], + }, + ], + baseline: 'current-law', + provenance: { source: 'manual' }, + }); + + // Then + const policyCreation = policyCreationRequest(); + expect(policyCreation).toBeDefined(); + expect(requestUrl(policyCreation).searchParams.get('policyengine_version')).toBe( + V2_POLICYENGINE_VERSION + ); + expect(await policyCreation!.json()).toMatchObject({ + tax_benefit_model_id: V2_MODEL_ID, + parameter_values: [{ parameter_id: V2_PARAMETER_IDS.CTC_AMOUNT, value: 3600 }], + }); + expect(saved.policyId).toBe(V2_POLICY_ID); + }); + + test('given the reform list is loaded again then each v2 policy is fetched only once', async () => { + // Given + const reforms = renderHook(() => useReformStore(), { + wrapper: wrapperFor(signedInAuthValue()), + }).result.current; + + // When + const first = await reforms.findMine('us'); + const second = await reforms.findMine('us'); + + // Then + expect(v2PolicyRequests()).toBe(1); + expect(first[0].parameters).toEqual([ + { + name: CTC_AMOUNT, + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 3600 }], + }, + ]); + expect(second[0].parameters).toEqual(first[0].parameters); + }); +}); diff --git a/app/src/tests/unit/types/v2User.test.ts b/app/src/tests/unit/types/v2User.test.ts new file mode 100644 index 000000000..d6882774c --- /dev/null +++ b/app/src/tests/unit/types/v2User.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'vitest'; +import type { UserPolicyCreateBody } from '@/types/v2User'; + +const V2_POLICY_ID = 'a1b2c3d4-0000-4000-8000-000000000001'; +const V1_POLICY_ID = 95762; + +describe('UserPolicyCreateBody', () => { + // Checked by `bun run typecheck`: each body below compiles only if the + // type requires a row to name a policy, as the API does. + test('given a saved reform row then it names a v2 policy, a v1 policy or both', () => { + // @ts-expect-error A row must name policy_id or legacy_policy_id. + const withoutPolicy: UserPolicyCreateBody = { country_id: 'us' }; + const onV2Policy: UserPolicyCreateBody = { country_id: 'us', policy_id: V2_POLICY_ID }; + const onV1Policy: UserPolicyCreateBody = { country_id: 'us', legacy_policy_id: V1_POLICY_ID }; + const onLinkedPolicies: UserPolicyCreateBody = { + country_id: 'us', + policy_id: V2_POLICY_ID, + legacy_policy_id: V1_POLICY_ID, + }; + + expect([withoutPolicy, onV2Policy, onV1Policy, onLinkedPolicies]).toHaveLength(4); + }); +}); diff --git a/app/src/types/v2User.ts b/app/src/types/v2User.ts index 63059ed0d..493485163 100644 --- a/app/src/types/v2User.ts +++ b/app/src/types/v2User.ts @@ -136,19 +136,28 @@ export interface SharedReportItem { /* Create and patch bodies (strict on the server: unknown keys are a 400). */ -export interface UserPolicyCreateBody { +/** + * The policy a saved row points at. The API takes a v2 policy + * (`policy_id`, from `/v2/policies`), a v1 policy (`legacy_policy_id`, + * from `POST /{country}/policy`), or both when they name the same policy; + * a row with neither is refused. + */ +export type UserPolicyReference = + | { policy_id: string; legacy_policy_id?: number } + | { policy_id?: string; legacy_policy_id: number }; + +export type UserPolicyCreateBody = UserPolicyReference & { country_id: AssociationCountryId; - legacy_policy_id: number; name?: string | null; description?: string | null; baseline?: BaselineWire | null; provenance?: ProvenanceWire | null; -} +}; export type UserPolicyPatchBody = Partial< Pick< UserPolicyCreateBody, - 'legacy_policy_id' | 'name' | 'description' | 'baseline' | 'provenance' + 'policy_id' | 'legacy_policy_id' | 'name' | 'description' | 'baseline' | 'provenance' > >; From 96c562067613f417a8879aa4c03bc97ed1dbf732 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:16:49 +0400 Subject: [PATCH 09/28] Ask again for on-demand parameters after the model catalog reloads Parameters loaded on demand live in the metadata store, which every metadata load empties, but the queries that loaded them stayed cached, so after another page reloaded the metadata (for example the older report builder, then Build again through browser history) a draft or a reform kept its placeholder and a save failed for lack of catalog IDs. Each load of the v2 model catalog now has an ID in the store (`v2LoadId`, the load's request ID). Batch queries are keyed by it, and `catalogParametersLoaded` takes parameters only for the current load, so after a reload the same names are asked for again and arrive in the new store. The v2 catalog version and load have one selector each. Co-Authored-By: Claude Opus 5.5 --- .../policyBrowser/V3PolicyBrowser.tsx | 5 +- app/src/hooks/useV3Parameters.ts | 37 ++++++----- app/src/hooks/useV3SearchCatalog.ts | 12 +--- app/src/reducers/metadataReducer.ts | 18 +++++- .../unit/hooks/useV3ParametersV2.test.tsx | 61 ++++++++++++++++++- .../unit/reducers/metadataReducerV2.test.ts | 29 +++++++++ app/src/types/metadata.ts | 5 ++ 7 files changed, 138 insertions(+), 29 deletions(-) diff --git a/app/src/components/flagship/policyBrowser/V3PolicyBrowser.tsx b/app/src/components/flagship/policyBrowser/V3PolicyBrowser.tsx index 2ecba4606..a477201ed 100644 --- a/app/src/components/flagship/policyBrowser/V3PolicyBrowser.tsx +++ b/app/src/components/flagship/policyBrowser/V3PolicyBrowser.tsx @@ -7,6 +7,7 @@ import { spacing } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useEnsureV3Parameters } from '@/hooks/useV3Parameters'; import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; +import { selectV2CatalogVersion } from '@/reducers/metadataReducer'; import type { RootState } from '@/store'; import type { ParameterTreeNode } from '@/types/metadata'; import PolicyBrowser, { PolicyBrowserView } from './PolicyBrowser'; @@ -96,9 +97,7 @@ function OnDemandPolicyBrowser({ valueFor, }: V3PolicyBrowserProps) { const countryId = useCurrentCountry(); - const policyengineVersion = useSelector((state: RootState) => - state.metadata.routes === 'v2' ? (state.metadata.v2Catalog?.policyengineVersion ?? null) : null - ); + const policyengineVersion = useSelector(selectV2CatalogVersion); const trailPaths = useMemo( () => (path?.startsWith(`${TREE_ROOT}.`) ? trailPathsOf(path) : []), [path] diff --git a/app/src/hooks/useV3Parameters.ts b/app/src/hooks/useV3Parameters.ts index d7aa087fb..681f0ce4c 100644 --- a/app/src/hooks/useV3Parameters.ts +++ b/app/src/hooks/useV3Parameters.ts @@ -5,7 +5,11 @@ import { fetchCatalogParameters } from '@/api/publicApi/catalogParameters'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; -import { catalogParametersLoaded } from '@/reducers/metadataReducer'; +import { + catalogParametersLoaded, + selectV2CatalogVersion, + selectV2LoadId, +} from '@/reducers/metadataReducer'; import type { AppDispatch, RootState } from '@/store'; /** @@ -35,18 +39,18 @@ export interface V3ParametersStatus { /** How long a batch's query stays cached without a reader; its parameters stay in the store. */ const PARAMETER_QUERY_GC_TIME = 5 * 60 * 1000; +/** + * A batch's query key. It names the model catalog load as well as the + * version: the parameters live in the store, which a new load empties, so + * a batch loaded under an earlier load is asked for again. + */ function catalogParametersKey( countryId: string, policyengineVersion: string | null, + loadId: string | null, names: readonly string[] ) { - return ['v2-catalog-parameters', countryId, policyengineVersion, ...names] as const; -} - -function selectV2CatalogVersion(state: RootState): string | null { - return state.metadata.routes === 'v2' - ? (state.metadata.v2Catalog?.policyengineVersion ?? null) - : null; + return ['v2-catalog-parameters', countryId, policyengineVersion, loadId, ...names] as const; } /** Which of `names` the store lacks values for, sorted, each once. */ @@ -65,10 +69,11 @@ async function loadIntoStore( dispatch: AppDispatch, countryId: string, policyengineVersion: string, + loadId: string, names: readonly string[] ): Promise { const loaded = await fetchCatalogParameters(countryId, policyengineVersion, names); - dispatch(catalogParametersLoaded({ countryId, policyengineVersion, ...loaded })); + dispatch(catalogParametersLoaded({ countryId, policyengineVersion, loadId, ...loaded })); return true; } @@ -87,6 +92,7 @@ function useV2EnsureParameters(names: readonly string[]): V3ParametersStatus { const dispatch = useDispatch(); const metadata = useFlagshipMetadataStatus(); const policyengineVersion = useSelector(selectV2CatalogVersion); + const loadId = useSelector(selectV2LoadId); const parameters = useSelector((state: RootState) => state.metadata.parameters); const missingNames = useSelector((state: RootState) => state.metadata.v2MissingParameterNames); const toLoad = useMemo( @@ -94,9 +100,9 @@ function useV2EnsureParameters(names: readonly string[]): V3ParametersStatus { [names, parameters, missingNames] ); const query = useQuery({ - queryKey: catalogParametersKey(countryId, policyengineVersion, toLoad), - queryFn: () => loadIntoStore(dispatch, countryId, policyengineVersion!, toLoad), - enabled: policyengineVersion !== null && toLoad.length > 0, + queryKey: catalogParametersKey(countryId, policyengineVersion, loadId, toLoad), + queryFn: () => loadIntoStore(dispatch, countryId, policyengineVersion!, loadId!, toLoad), + enabled: policyengineVersion !== null && loadId !== null && toLoad.length > 0, staleTime: Infinity, gcTime: PARAMETER_QUERY_GC_TIME, }); @@ -142,6 +148,7 @@ function useV2LoadParameters(): LoadV3Parameters { (names: readonly string[]) => { const state = store.getState(); const policyengineVersion = selectV2CatalogVersion(state); + const loadId = selectV2LoadId(state); const toLoad = namesToLoad( state.metadata.parameters, state.metadata.v2MissingParameterNames, @@ -150,13 +157,13 @@ function useV2LoadParameters(): LoadV3Parameters { if (toLoad.length === 0) { return null; } - if (policyengineVersion === null) { + if (policyengineVersion === null || loadId === null) { return Promise.reject(new Error('The model catalog has not loaded yet.')); } return queryClient .fetchQuery({ - queryKey: catalogParametersKey(countryId, policyengineVersion, toLoad), - queryFn: () => loadIntoStore(dispatch, countryId, policyengineVersion, toLoad), + queryKey: catalogParametersKey(countryId, policyengineVersion, loadId, toLoad), + queryFn: () => loadIntoStore(dispatch, countryId, policyengineVersion, loadId, toLoad), staleTime: Infinity, gcTime: PARAMETER_QUERY_GC_TIME, }) diff --git a/app/src/hooks/useV3SearchCatalog.ts b/app/src/hooks/useV3SearchCatalog.ts index da07e4d2f..83a39d966 100644 --- a/app/src/hooks/useV3SearchCatalog.ts +++ b/app/src/hooks/useV3SearchCatalog.ts @@ -16,6 +16,7 @@ import { type ParameterSearchEntry, type ParameterSearchIndex, } from '@/libs/parameterSearch'; +import { selectV2CatalogVersion } from '@/reducers/metadataReducer'; import type { RootState } from '@/store'; /** @@ -79,17 +80,10 @@ function useV1SearchCatalog(): V3SearchCatalog { }; } -/** The current catalog version's PolicyEngine.py version, once the model catalog is in. */ -function useV2CatalogVersion(): string | null { - return useSelector((state: RootState) => - state.metadata.routes === 'v2' ? (state.metadata.v2Catalog?.policyengineVersion ?? null) : null - ); -} - function useV2SearchCatalog(): V3SearchCatalog { const countryId = useCurrentCountry(); const metadata = useFlagshipMetadataStatus(); - const policyengineVersion = useV2CatalogVersion(); + const policyengineVersion = useSelector(selectV2CatalogVersion); const query = useQuery({ queryKey: v2SearchFileKey(countryId, policyengineVersion), queryFn: () => fetchParameterSearchFile(countryId, policyengineVersion!), @@ -165,7 +159,7 @@ export const useV3SearchCatalog: () => V3SearchCatalog = */ function useV2SearchFileInBackground(): void { const countryId = useCurrentCountry(); - const policyengineVersion = useV2CatalogVersion(); + const policyengineVersion = useSelector(selectV2CatalogVersion); const queryClient = useQueryClient(); useEffect(() => { if (policyengineVersion === null) { diff --git a/app/src/reducers/metadataReducer.ts b/app/src/reducers/metadataReducer.ts index c25b13360..a7d55947e 100644 --- a/app/src/reducers/metadataReducer.ts +++ b/app/src/reducers/metadataReducer.ts @@ -107,15 +107,18 @@ const metadataSlice = createSlice({ action: PayloadAction<{ countryId: string; policyengineVersion: string; + /** The model catalog load the parameters were asked for under. */ + loadId: string; parameters: ParameterMetadataCollection; parameterIds: Record; missingNames: string[]; }> ) { - const { countryId, policyengineVersion, parameters, parameterIds, missingNames } = + const { countryId, policyengineVersion, loadId, parameters, parameterIds, missingNames } = action.payload; if ( state.routes !== 'v2' || + state.v2LoadId !== loadId || state.currentCountry !== countryId || state.v2Catalog?.policyengineVersion !== policyengineVersion ) { @@ -175,6 +178,7 @@ const metadataSlice = createSlice({ // v3 reads the tree one level at a time from the v2 routes. state.routes = 'v2'; state.v2Catalog = body.v2Catalog ?? null; + state.v2LoadId = action.meta.requestId; state.parameterTree = null; return; } @@ -203,6 +207,18 @@ const metadataSlice = createSlice({ export const { setCurrentCountry, clearMetadata, catalogParametersLoaded } = metadataSlice.actions; +/** The v2 catalog version the store holds, once its model catalog is in; null otherwise. */ +export function selectV2CatalogVersion(state: { metadata: MetadataState }): string | null { + return state.metadata.routes === 'v2' + ? (state.metadata.v2Catalog?.policyengineVersion ?? null) + : null; +} + +/** The load of the v2 model catalog the store holds; null before one is in. */ +export function selectV2LoadId(state: { metadata: MetadataState }): string | null { + return state.metadata.routes === 'v2' ? (state.metadata.v2LoadId ?? null) : null; +} + // currentCountry identifies both the active request and the metadata used by readiness checks. export default metadataSlice.reducer; diff --git a/app/src/tests/unit/hooks/useV3ParametersV2.test.tsx b/app/src/tests/unit/hooks/useV3ParametersV2.test.tsx index 324ebfaaf..58274f525 100644 --- a/app/src/tests/unit/hooks/useV3ParametersV2.test.tsx +++ b/app/src/tests/unit/hooks/useV3ParametersV2.test.tsx @@ -1,6 +1,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { useEnsureV3Parameters, useLoadV3Parameters } from '@/hooks/useV3Parameters'; +import { clearMetadata, fetchMetadataThunk, metadataRequest } from '@/reducers/metadataReducer'; import { store } from '@/store'; import { catalogParametersResult, @@ -16,7 +17,11 @@ import { STANDARD_DEDUCTION_JOINT, STANDARD_DEDUCTION_SINGLE, } from '@/tests/fixtures/api/publicApi/searchFileMocks'; -import { seedV2ModelCatalog, v3Wrapper } from '@/tests/fixtures/hooks/v3MetadataWrapper'; +import { + seedV2ModelCatalog, + testQueryClient, + v3Wrapper, +} from '@/tests/fixtures/hooks/v3MetadataWrapper'; /** v3 after the switch: V3_API_ROUTES is 'v2'. */ vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ @@ -162,3 +167,57 @@ describe('useLoadV3Parameters with v3 on the v2 routes', () => { expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(1); }); }); + +describe('useEnsureV3Parameters after the metadata reloads', () => { + let fetchStub: ReturnType; + + beforeEach(async () => { + fetchStub = stubPublicApiFetch({ [PARAMETERS_PATH]: answerCatalogParameters }); + await seedV2ModelCatalog(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('given the store was emptied by a new metadata load then the parameters are asked for again', async () => { + // Given — one query client for the session, as the app has + const queryClient = testQueryClient(); + const first = renderHook(() => useEnsureV3Parameters([STANDARD_DEDUCTION_SINGLE]), { + wrapper: v3Wrapper(queryClient), + }); + await waitFor(() => expect(first.result.current.status).toBe('ready')); + first.unmount(); + + // When — another page loads v1's metadata, then v3's again + store.dispatch(clearMetadata()); + await store.dispatch(fetchMetadataThunk(metadataRequest('us', 'v2'))); + const second = renderHook(() => useEnsureV3Parameters([STANDARD_DEDUCTION_SINGLE]), { + wrapper: v3Wrapper(queryClient), + }); + + // Then + await waitFor(() => expect(second.result.current.status).toBe('ready')); + expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(2); + expect(store.getState().metadata.parameters[STANDARD_DEDUCTION_SINGLE]?.values).toBeDefined(); + }); + + test('given the store was emptied then the load function asks again too', async () => { + const queryClient = testQueryClient(); + const { result } = renderHook(() => useLoadV3Parameters(), { wrapper: v3Wrapper(queryClient) }); + await act(async () => { + await result.current([STANDARD_DEDUCTION_SINGLE]); + }); + + store.dispatch(clearMetadata()); + await store.dispatch(fetchMetadataThunk(metadataRequest('us', 'v2'))); + await act(async () => { + await result.current([STANDARD_DEDUCTION_SINGLE]); + }); + + expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(2); + expect(store.getState().metadata.v2ParameterIds?.[STANDARD_DEDUCTION_SINGLE]).toBe( + STANDARD_DEDUCTION_SINGLE_ID + ); + }); +}); diff --git a/app/src/tests/unit/reducers/metadataReducerV2.test.ts b/app/src/tests/unit/reducers/metadataReducerV2.test.ts index 91eb27aba..197c4752e 100644 --- a/app/src/tests/unit/reducers/metadataReducerV2.test.ts +++ b/app/src/tests/unit/reducers/metadataReducerV2.test.ts @@ -126,6 +126,7 @@ describe('metadataReducer with the v2 routes', () => { catalogParametersLoaded({ countryId: 'us', policyengineVersion: TEST_POLICYENGINE_VERSION, + loadId: store.getState().metadata.v2LoadId!, parameters: { 'gov.example.rate': { type: 'parameter', @@ -155,6 +156,7 @@ describe('metadataReducer with the v2 routes', () => { catalogParametersLoaded({ countryId: 'us', policyengineVersion: '0.0.1', + loadId: store.getState().metadata.v2LoadId!, parameters: { 'gov.example.rate': { type: 'parameter', parameter: 'gov.example.rate', label: 'Rate' }, }, @@ -165,4 +167,31 @@ describe('metadataReducer with the v2 routes', () => { expect(store.getState().metadata.parameters).toEqual({}); }); + + test('given parameters asked for under an earlier model catalog load then they are dropped', async () => { + // Given — the store loaded the model catalog, then loaded it again + mockFetchV2ModelCatalogMetadata.mockResolvedValue(v2Payload()); + const store = testStore(); + await store.dispatch(fetchMetadataThunk(metadataRequest('us', 'v2'))); + const earlierLoad = store.getState().metadata.v2LoadId!; + await store.dispatch(fetchMetadataThunk(metadataRequest('us', 'v2'))); + + // When + store.dispatch( + catalogParametersLoaded({ + countryId: 'us', + policyengineVersion: TEST_POLICYENGINE_VERSION, + loadId: earlierLoad, + parameters: { + 'gov.example.rate': { type: 'parameter', parameter: 'gov.example.rate', label: 'Rate' }, + }, + parameterIds: {}, + missingNames: [], + }) + ); + + // Then + expect(store.getState().metadata.v2LoadId).not.toBe(earlierLoad); + expect(store.getState().metadata.parameters).toEqual({}); + }); }); diff --git a/app/src/types/metadata.ts b/app/src/types/metadata.ts index 20ff4722a..f5fbba199 100644 --- a/app/src/types/metadata.ts +++ b/app/src/types/metadata.ts @@ -116,6 +116,11 @@ export interface MetadataState { */ v2ParameterIds?: Record; v2MissingParameterNames?: string[]; + /** + * Which load of the v2 model catalog the store holds: a new load empties + * the parameters loaded on demand, so their queries are keyed by it. + */ + v2LoadId?: string; // Computed parameter tree for policy creation UI (built when metadata is fetched) parameterTree: ParameterTreeNode | null; From fe58c82d7006c0583e1f83fd257da84fa9c4d202 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:26:23 +0400 Subject: [PATCH 10/28] Load a report's parameters before parsing its policies The report hooks parsed each policy against the metadata store as soon as the metadata was ready. On the v2 routes that is the small model catalog, with no parameters loaded yet, so parsing failed ("Missing parameter metadata") and the report page, which takes the names to load from the parsed policies, never asked for them. `useV3Policies` now reads each policy as stored (`GET /{country}/policy/ {id}`, under its own query key), makes sure of the parameters the stored policies name (loaded in one request on v2; already in on v1), and only then parses them; a policy that cannot be read or parsed is listed as failed, and retry asks again for what failed. `useFlagshipReport` and `useFlagshipHouseholdPolicies` both use it, so they share the stored policies as before. Co-Authored-By: Claude Opus 5.5 --- app/src/hooks/useFlagshipHousehold.ts | 30 ++--- app/src/hooks/useFlagshipReport.ts | 31 ++--- app/src/hooks/useV3Policies.ts | 80 +++++++++++ app/src/libs/queryKeys.ts | 2 + .../api/publicApi/v2ReportPolicyMocks.ts | 32 +++++ .../report/HouseholdBreakdowns.test.tsx | 4 +- .../unit/hooks/useFlagshipReportV2.test.tsx | 126 ++++++++++++++++++ 7 files changed, 261 insertions(+), 44 deletions(-) create mode 100644 app/src/hooks/useV3Policies.ts create mode 100644 app/src/tests/fixtures/api/publicApi/v2ReportPolicyMocks.ts create mode 100644 app/src/tests/unit/hooks/useFlagshipReportV2.test.tsx diff --git a/app/src/hooks/useFlagshipHousehold.ts b/app/src/hooks/useFlagshipHousehold.ts index d82d928e4..e51f99973 100644 --- a/app/src/hooks/useFlagshipHousehold.ts +++ b/app/src/hooks/useFlagshipHousehold.ts @@ -1,12 +1,7 @@ import { useQuery } from '@tanstack/react-query'; -import { PolicyAdapter } from '@/adapters/PolicyAdapter'; -import { fetchPolicyById } from '@/api/policy'; -import { useParameterMetadata } from '@/hooks/useParameterMetadata'; -import { useParallelQueries } from '@/hooks/utils/queryUtils'; +import { useV3Policies } from '@/hooks/useV3Policies'; import { runFlagshipHouseholdPayload } from '@/libs/flagship/householdRun'; -import { policyKeys } from '@/libs/queryKeys'; import type { V1HouseholdCreateEnvelope } from '@/models/household/v1Types'; -import type { Policy } from '@/types/ingredients/Policy'; /** * A household built in full (the household builder's model), run for a @@ -60,24 +55,19 @@ export function useFlagshipHouseholdPolicies({ policyIds: string[]; enabled: boolean; }) { - const { parameters, isReady } = useParameterMetadata(countryId); - const results = useParallelQueries(policyIds, { - queryKey: policyKeys.byId, - queryFn: async (policyId) => - PolicyAdapter.fromMetadata(await fetchPolicyById(countryId, policyId), parameters), - enabled: enabled && isReady && policyIds.length > 0, - staleTime: 5 * 60 * 1000, - structuralSharing: false, - }); - const policies = results.queries - .map((query) => query.data) - .filter((policy): policy is Policy => !!policy); - const error = (results.error as Error | null) ?? null; + // Read as stored, then parsed once the parameters each names are in, as + // the report page reads them (under the same keys). + const results = useV3Policies(countryId, policyIds, enabled); + const { policies } = results; + const error = + results.failedPolicyIds.length > 0 + ? new Error(`Could not load policy ${results.failedPolicyIds.join(', ')}`) + : null; return { policies, /** Waiting for the model's parameters, or for a policy. */ isLoading: enabled && !error && policies.length < new Set(policyIds).size, error, - retry: () => Promise.all(results.queries.map((query) => query.refetch())), + retry: results.retry, }; } diff --git a/app/src/hooks/useFlagshipReport.ts b/app/src/hooks/useFlagshipReport.ts index 6ab5bda02..f9067d719 100644 --- a/app/src/hooks/useFlagshipReport.ts +++ b/app/src/hooks/useFlagshipReport.ts @@ -1,16 +1,15 @@ import { useQuery } from '@tanstack/react-query'; -import { PolicyAdapter, ReportAdapter, SimulationAdapter } from '@/adapters'; -import { fetchPolicyById } from '@/api/policy'; +import { ReportAdapter, SimulationAdapter } from '@/adapters'; import { fetchReportById } from '@/api/report'; import { fetchSimulationById } from '@/api/simulation'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; -import { useParameterMetadata } from '@/hooks/useParameterMetadata'; +import { useV3Policies } from '@/hooks/useV3Policies'; import { isNotFoundError, retryUnlessNotFound } from '@/libs/flagship/loadError'; import { GC_TIME_5_MIN } from '@/libs/queryConfig'; import { Policy } from '@/types/ingredients/Policy'; import { Report } from '@/types/ingredients/Report'; import { Simulation } from '@/types/ingredients/Simulation'; -import { policyKeys, reportKeys, simulationKeys } from '../libs/queryKeys'; +import { reportKeys, simulationKeys } from '../libs/queryKeys'; import { extractUniqueIds, useParallelQueries } from './utils/queryUtils'; /** @@ -55,8 +54,6 @@ export interface FlagshipReportData { */ export function useFlagshipReport(id: string): FlagshipReportData { const country = useCurrentCountry(); - const { parameters: parameterMetadata, isReady: isParameterMetadataReady } = - useParameterMetadata(country); const direct = isApiReportId(id); const { @@ -87,19 +84,10 @@ export function useFlagshipReport(id: string): FlagshipReportData { .filter((s): s is Simulation => !!s); const policyIds = extractUniqueIds(simulations, 'policyId'); - const policyResults = useParallelQueries(policyIds, { - queryKey: policyKeys.byId, - queryFn: async (policyId) => - PolicyAdapter.fromMetadata(await fetchPolicyById(country, policyId), parameterMetadata), - enabled: direct && isParameterMetadataReady && policyIds.length > 0, - staleTime: 5 * 60 * 1000, - structuralSharing: false, - }); - const policies = policyResults.queries.map((q) => q.data).filter((p): p is Policy => !!p); - const failedPolicyIds = policyIds.filter((_, index) => { - const query = policyResults.queries[index]; - return !!query?.error && !query.data; - }); + // Each policy is read as stored, the parameters it names are loaded (on + // the v2 routes) or already in (v1), and then it is parsed. + const policyResults = useV3Policies(country, policyIds, direct); + const { policies, failedPolicyIds } = policyResults; if (!direct) { return { @@ -133,9 +121,8 @@ export function useFlagshipReport(id: string): FlagshipReportData { retry: () => Promise.all([ reportError ? refetchReport() : null, - ...[...simulationResults.queries, ...policyResults.queries] - .filter((query) => query.error) - .map((query) => query.refetch()), + ...simulationResults.queries.filter((query) => query.error).map((query) => query.refetch()), + failedPolicyIds.length > 0 ? policyResults.retry() : null, ]), }; } diff --git a/app/src/hooks/useV3Policies.ts b/app/src/hooks/useV3Policies.ts new file mode 100644 index 000000000..336099e60 --- /dev/null +++ b/app/src/hooks/useV3Policies.ts @@ -0,0 +1,80 @@ +import { useSelector } from 'react-redux'; +import { PolicyAdapter } from '@/adapters'; +import { fetchPolicyById } from '@/api/policy'; +import { useEnsureV3Parameters } from '@/hooks/useV3Parameters'; +import { useParallelQueries } from '@/hooks/utils/queryUtils'; +import { policyKeys } from '@/libs/queryKeys'; +import type { RootState } from '@/store'; +import type { Policy } from '@/types/ingredients/Policy'; +import type { PolicyMetadata } from '@/types/metadata/policyMetadata'; + +/** + * The policies a v3 report shows, in the app's model. A policy is parsed + * against the metadata of the parameters it names (which types its + * values), so each is first read as stored (`GET /{country}/policy/{id}`), + * then the parameters it names are made sure of, then it is parsed. On the + * v1 routes the whole metadata holds every parameter; on the v2 routes the + * named ones load in one request. A policy that cannot be read or parsed is + * listed as failed. + */ +export interface V3Policies { + policies: Policy[]; + /** The policies that failed to load or parse, with nothing to show. */ + failedPolicyIds: string[]; + /** Waiting for a policy, or for the parameters it names. */ + isLoading: boolean; + /** Asks again for whatever failed. */ + retry: () => Promise; +} + +function parse(stored: PolicyMetadata, parameters: RootState['metadata']['parameters']) { + try { + return PolicyAdapter.fromMetadata(stored, parameters); + } catch { + return null; + } +} + +export function useV3Policies( + countryId: string, + policyIds: string[], + enabled: boolean +): V3Policies { + const stored = useParallelQueries(policyIds, { + queryKey: policyKeys.storedById, + queryFn: (policyId) => fetchPolicyById(countryId, policyId), + enabled: enabled && policyIds.length > 0, + staleTime: 5 * 60 * 1000, + }); + const storedPolicies = stored.queries.map((query) => query.data); + // A report holds two or three policies, so naming and parsing them on + // each render costs little; the parameter load compares names by content. + const names = [ + ...new Set( + storedPolicies.flatMap((policy) => (policy ? Object.keys(policy.policy_json ?? {}) : [])) + ), + ]; + const parameterStatus = useEnsureV3Parameters(names); + const parameters = useSelector((state: RootState) => state.metadata.parameters); + const ready = parameterStatus.status === 'ready'; + const parsed = storedPolicies.map((policy) => + policy && ready ? parse(policy, parameters) : undefined + ); + + const failedPolicyIds = policyIds.filter((_, index) => { + const query = stored.queries[index]; + return (!!query?.error && !query.data) || parsed[index] === null; + }); + const policies = parsed.filter((policy): policy is Policy => !!policy); + const waitingForParameters = storedPolicies.some(Boolean) && parameterStatus.status === 'loading'; + return { + policies, + failedPolicyIds, + isLoading: enabled && (stored.isLoading || waitingForParameters), + retry: () => + Promise.all([ + ...stored.queries.filter((query) => query.error).map((query) => query.refetch()), + parameterStatus.status === 'error' ? parameterStatus.retry() : null, + ]), + }; +} diff --git a/app/src/libs/queryKeys.ts b/app/src/libs/queryKeys.ts index 5f6260fff..ba14d3e7e 100644 --- a/app/src/libs/queryKeys.ts +++ b/app/src/libs/queryKeys.ts @@ -69,6 +69,8 @@ export const policyKeys = { all: ['policies'] as const, byId: (policyId: string) => [...policyKeys.all, 'policy_id', policyId] as const, byUser: (userId: string) => [...policyKeys.all, 'user_id', userId] as const, + /** A v1 policy as stored (`GET /{country}/policy/{id}`), before it is parsed. */ + storedById: (policyId: string) => [...policyKeys.all, 'stored', policyId] as const, }; export const householdKeys = { diff --git a/app/src/tests/fixtures/api/publicApi/v2ReportPolicyMocks.ts b/app/src/tests/fixtures/api/publicApi/v2ReportPolicyMocks.ts new file mode 100644 index 000000000..d00a3b34b --- /dev/null +++ b/app/src/tests/fixtures/api/publicApi/v2ReportPolicyMocks.ts @@ -0,0 +1,32 @@ +import { + STANDARD_DEDUCTION_JOINT, + STANDARD_DEDUCTION_SINGLE, +} from '@/tests/fixtures/api/publicApi/searchFileMocks'; +import type { PolicyMetadata } from '@/types/metadata/policyMetadata'; + +/** A report's reform policy as v1 stores it: a larger single standard deduction. */ +export const REFORM_POLICY_METADATA: PolicyMetadata = { + id: '98557', + country_id: 'us', + api_version: 'v1', + policy_json: { [STANDARD_DEDUCTION_SINGLE]: { '2026-01-01.2100-12-31': 20000 } }, + policy_hash: 'reform-hash', +}; + +/** A saved reform the report is compared with: a larger joint standard deduction. */ +export const SAVED_BASELINE_POLICY_METADATA: PolicyMetadata = { + id: '77001', + country_id: 'us', + api_version: 'v1', + policy_json: { [STANDARD_DEDUCTION_JOINT]: { '2026-01-01.2100-12-31': 40000 } }, + policy_hash: 'baseline-hash', +}; + +/** Current law, which names no parameter. */ +export const CURRENT_LAW_POLICY_METADATA: PolicyMetadata = { + id: '2', + country_id: 'us', + api_version: 'v1', + policy_json: {}, + policy_hash: 'current-law-hash', +}; diff --git a/app/src/tests/unit/components/flagship/report/HouseholdBreakdowns.test.tsx b/app/src/tests/unit/components/flagship/report/HouseholdBreakdowns.test.tsx index 5b61bd260..7cb728ba5 100644 --- a/app/src/tests/unit/components/flagship/report/HouseholdBreakdowns.test.tsx +++ b/app/src/tests/unit/components/flagship/report/HouseholdBreakdowns.test.tsx @@ -24,8 +24,8 @@ vi.mock('@/api/policy', () => ({ fetchPolicyById: (...args: unknown[]) => mockFetchPolicyById(...args), })); // The model's parameters, loaded: the policies parse at once. -vi.mock('@/hooks/useParameterMetadata', () => ({ - useParameterMetadata: () => ({ parameters: {}, isReady: true }), +vi.mock('@/hooks/useV3Parameters', () => ({ + useEnsureV3Parameters: () => ({ status: 'ready', waiting: false, message: null, retry: vi.fn() }), })); // The variation request is the v2 hook's own, tested on its own: here it // records what the v2 pages ask for, and stays loading. diff --git a/app/src/tests/unit/hooks/useFlagshipReportV2.test.tsx b/app/src/tests/unit/hooks/useFlagshipReportV2.test.tsx new file mode 100644 index 000000000..db4a125a7 --- /dev/null +++ b/app/src/tests/unit/hooks/useFlagshipReportV2.test.tsx @@ -0,0 +1,126 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { useFlagshipReport } from '@/hooks/useFlagshipReport'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { catalogParametersResult } from '@/tests/fixtures/api/publicApi/catalogParametersMocks'; +import { + jsonResponse, + requestsTo, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { + STANDARD_DEDUCTION_JOINT, + STANDARD_DEDUCTION_SINGLE, +} from '@/tests/fixtures/api/publicApi/searchFileMocks'; +import { + CURRENT_LAW_POLICY_METADATA, + REFORM_POLICY_METADATA, + SAVED_BASELINE_POLICY_METADATA, +} from '@/tests/fixtures/api/publicApi/v2ReportPolicyMocks'; +import { + BASELINE_SIMULATION_ID, + EXISTING_REPORT_ID, + EXISTING_REPORT_METADATA, + nationwideSimulationMetadata, + REFORM_SIMULATION_ID, +} from '@/tests/fixtures/hooks/useFlagshipReportMocks'; +import { seedV2ModelCatalog, v3Wrapper } from '@/tests/fixtures/hooks/v3MetadataWrapper'; + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +const POLICIES = new Map( + [REFORM_POLICY_METADATA, SAVED_BASELINE_POLICY_METADATA, CURRENT_LAW_POLICY_METADATA].map( + (policy) => [policy.id, policy] + ) +); +const baselinePolicyId = vi.hoisted(() => ({ value: '2' })); + +vi.mock('@/api/report', async (importOriginal) => ({ + ...(await importOriginal()), + fetchReportById: async () => EXISTING_REPORT_METADATA, +})); +vi.mock('@/api/simulation', async (importOriginal) => ({ + ...(await importOriginal()), + fetchSimulationById: async (_country: string, simulationId: string) => + simulationId === BASELINE_SIMULATION_ID + ? nationwideSimulationMetadata(BASELINE_SIMULATION_ID, baselinePolicyId.value) + : nationwideSimulationMetadata(REFORM_SIMULATION_ID, REFORM_POLICY_METADATA.id), +})); +vi.mock('@/api/policy', async (importOriginal) => ({ + ...(await importOriginal()), + fetchPolicyById: async (_country: string, policyId: string) => POLICIES.get(policyId), +})); + +const PARAMETERS_PATH = '/v2/model-catalog/parameters'; + +describe('useFlagshipReport with v3 on the v2 routes', () => { + let fetchStub: ReturnType; + + beforeEach(async () => { + fetchStub = stubPublicApiFetch({ + [PARAMETERS_PATH]: (url) => + jsonResponse({ + status: 'ok', + message: null, + result: catalogParametersResult(url.searchParams.getAll('name')), + }), + }); + await seedV2ModelCatalog(); + }); + + afterEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + baselinePolicyId.value = '2'; + }); + + test('given a report opened fresh then its policies load the parameters they name, then parse', async () => { + // When + const { result } = renderHook(() => useFlagshipReport(EXISTING_REPORT_ID), { + wrapper: v3Wrapper(), + }); + + // Then + await waitFor(() => expect(result.current.policies).toHaveLength(2)); + expect(result.current.failedPolicyIds).toEqual([]); + const reform = result.current.policies.find( + (policy) => policy.id === REFORM_POLICY_METADATA.id + ); + expect(reform?.parameters).toEqual([ + { + name: STANDARD_DEDUCTION_SINGLE, + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 20000 }], + }, + ]); + expect( + requestsTo(fetchStub, PARAMETERS_PATH).flatMap((url) => url.searchParams.getAll('name')) + ).toEqual([STANDARD_DEDUCTION_SINGLE]); + }); + + test('given a report compared with a saved reform then both policies load and parse', async () => { + // Given + baselinePolicyId.value = SAVED_BASELINE_POLICY_METADATA.id; + + // When + const { result } = renderHook(() => useFlagshipReport(EXISTING_REPORT_ID), { + wrapper: v3Wrapper(), + }); + + // Then + await waitFor(() => expect(result.current.policies).toHaveLength(2)); + expect(result.current.failedPolicyIds).toEqual([]); + const names = requestsTo(fetchStub, PARAMETERS_PATH).flatMap((url) => + url.searchParams.getAll('name') + ); + expect(names.sort()).toEqual([STANDARD_DEDUCTION_JOINT, STANDARD_DEDUCTION_SINGLE].sort()); + const baseline = result.current.policies.find( + (policy) => policy.id === SAVED_BASELINE_POLICY_METADATA.id + ); + expect(baseline?.parameters?.[0].name).toBe(STANDARD_DEDUCTION_JOINT); + }); +}); From 397e06a80b4644b22b03c47bffc1ab56537bb781 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:31:28 +0400 Subject: [PATCH 11/28] Load no metadata on pages that only redirect into v3, after the switch The country index and the tracker's old address only send the browser on (into v3 when it is on), yet they started v1's whole-model download first. `MetadataBootstrap` now picks a page's routes from all its segments with `metadataRoutesForPage`: once `V3_API_ROUTES` is 'v2' those two pages load nothing, and the page they redirect to loads its own. Before the switch they load v1 as today. The country-ID architecture document describes the routes argument. Co-Authored-By: Claude Opus 5.5 --- app/src/docs/ARCHITECTURE-COUNTRY-ID.md | 7 +++-- app/src/hooks/useMetadata.ts | 7 ++++- app/src/libs/flagship/v3ApiRoutes.ts | 26 ++++++++++++++++++ .../calculator/CalculatorProviders.test.tsx | 27 +++++++++++++------ app/src/tests/unit/hooks/useMetadata.test.tsx | 10 +++++++ .../unit/libs/flagship/v3ApiRoutes.test.ts | 16 +++++++++++ .../src/app/[countryId]/providers.tsx | 9 ++++--- 7 files changed, 87 insertions(+), 15 deletions(-) diff --git a/app/src/docs/ARCHITECTURE-COUNTRY-ID.md b/app/src/docs/ARCHITECTURE-COUNTRY-ID.md index fc190c930..e32878d5b 100644 --- a/app/src/docs/ARCHITECTURE-COUNTRY-ID.md +++ b/app/src/docs/ARCHITECTURE-COUNTRY-ID.md @@ -41,9 +41,11 @@ Components read the country from this hook, not from Redux. ## Redux copy and metadata loading -`MetadataBootstrap` in `providers.tsx` copies the country into Redux with `setCurrentCountry(countryId)` and calls `useFetchMetadata(countryId)`. Loading is non-blocking: pages render while metadata loads. +`MetadataBootstrap` in `providers.tsx` copies the country into Redux with `setCurrentCountry(countryId)` and calls `useFetchMetadata(countryId, routes)`. Loading is non-blocking: pages render while metadata loads. -`useFetchMetadata` (`src/hooks/useMetadata.ts`) dispatches `fetchMetadataThunk(countryId)` only when there is no metadata yet or it belongs to another country, so moving between pages of one country does not refetch. Hooks that need metadata for the current country compare `metadata.currentCountry` with the country they were given. +`routes` comes from `metadataRoutesForPage(useSelectedLayoutSegments())` (`src/libs/flagship/v3ApiRoutes.ts`): `'v1'` (`GET /{country}/metadata`, the whole model) for the older report builder, the routes `V3_API_ROUTES` names for the v3 workspace (`'v2'` loads only the small `GET /v2/model-catalog`, and v3 loads parameters on demand), and, once `V3_API_ROUTES` is `'v2'`, `null` (load nothing) for pages that only redirect into v3 (the country index and the tracker's old address). + +`useFetchMetadata` (`src/hooks/useMetadata.ts`) dispatches `fetchMetadataThunk(metadataRequest(countryId, routes))` only when there is no metadata yet, it belongs to another country, or it came from other routes, so moving between pages of one country does not refetch. Hooks that need metadata for the current country compare `metadata.currentCountry` with the country they were given. ## Implementation checklist @@ -62,4 +64,5 @@ When adding a country-dependent feature: - `src/contexts/CountryContext.tsx`: `CountryProvider` and `useCountryContext` - `src/hooks/useCurrentCountry.ts`: hook that reads the country - `src/hooks/useMetadata.ts`: `useFetchMetadata` +- `src/libs/flagship/v3ApiRoutes.ts`: `V3_API_ROUTES` and which routes a page loads metadata from - `src/reducers/metadataReducer.ts`: metadata state and `setCurrentCountry` diff --git a/app/src/hooks/useMetadata.ts b/app/src/hooks/useMetadata.ts index b58b4c7f5..ec32c1182 100644 --- a/app/src/hooks/useMetadata.ts +++ b/app/src/hooks/useMetadata.ts @@ -14,15 +14,20 @@ import type { MetadataRoutes } from '@/types/metadata'; * - The metadata in state came from other routes than `routes` (v1's * whole-model route, or v2's small model catalog; see v3ApiRoutes) * + * Nothing loads when `routes` is null (a page that only redirects). + * * Components should use useSelector to read metadata from Redux state */ -export function useFetchMetadata(countryId: string, routes: MetadataRoutes = 'v1'): void { +export function useFetchMetadata(countryId: string, routes: MetadataRoutes | null = 'v1'): void { const dispatch = useDispatch(); const metadata = useSelector((state: RootState) => state.metadata); const loadedRoutes = metadata.routes ?? 'v1'; useEffect(() => { // Only fetch if we don't already have metadata for this country from these routes + if (routes === null) { + return; + } if (!metadata.version || countryId !== metadata.currentCountry || loadedRoutes !== routes) { dispatch(fetchMetadataThunk(metadataRequest(countryId, routes))); } diff --git a/app/src/libs/flagship/v3ApiRoutes.ts b/app/src/libs/flagship/v3ApiRoutes.ts index 5d5bced4a..3ae48116d 100644 --- a/app/src/libs/flagship/v3ApiRoutes.ts +++ b/app/src/libs/flagship/v3ApiRoutes.ts @@ -35,3 +35,29 @@ export function metadataRoutesForRouteGroup( ): V3ApiRoutes { return routeGroup === V3_WORKSPACE_ROUTE_GROUP ? v3Routes : 'v1'; } + +/** + * Pages that only send the browser on into v3 (the country's index, and + * the bill tracker's old address), by their segments below `[countryId]`. + */ +const REDIRECT_ONLY_PAGES: readonly (readonly string[])[] = [[], ['(calculator)', 'tracker']]; + +/** + * Where model metadata loads from for a page, by its segments below + * `[countryId]` (`useSelectedLayoutSegments`). After the switch, a page + * that only redirects into v3 loads nothing (its destination loads its + * own), so it does not start v1's large download; before the switch it + * loads v1 as it always has. Every other page follows its route group. + */ +export function metadataRoutesForPage( + segments: readonly string[], + v3Routes: V3ApiRoutes = V3_API_ROUTES +): MetadataRoutes | null { + const redirectOnly = REDIRECT_ONLY_PAGES.some( + (page) => page.length === segments.length && page.every((segment, i) => segments[i] === segment) + ); + if (redirectOnly && v3Routes === 'v2') { + return null; + } + return metadataRoutesForRouteGroup(segments[0] ?? null, v3Routes); +} diff --git a/app/src/tests/unit/calculator/CalculatorProviders.test.tsx b/app/src/tests/unit/calculator/CalculatorProviders.test.tsx index 280f5bd07..f50dd5d1e 100644 --- a/app/src/tests/unit/calculator/CalculatorProviders.test.tsx +++ b/app/src/tests/unit/calculator/CalculatorProviders.test.tsx @@ -3,11 +3,11 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { CountryProvider } from '@/contexts/CountryContext'; import { CalculatorProviders } from '../../../../../calculator-app/src/app/[countryId]/providers'; -const selectedLayout = vi.hoisted(() => ({ routeGroup: null as string | null })); +const selectedLayout = vi.hoisted(() => ({ segments: [] as string[] })); const mockUseFetchMetadata = vi.hoisted(() => vi.fn()); vi.mock('next/navigation', () => ({ - useSelectedLayoutSegment: () => selectedLayout.routeGroup, + useSelectedLayoutSegments: () => selectedLayout.segments, })); vi.mock('@/hooks/useMetadata', () => ({ @@ -23,13 +23,12 @@ vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => { return { ...original, V3_API_ROUTES: 'v2', - metadataRoutesForRouteGroup: (routeGroup: string | null) => - original.metadataRoutesForRouteGroup(routeGroup, 'v2'), + metadataRoutesForPage: (segments: string[]) => original.metadataRoutesForPage(segments, 'v2'), }; }); -function renderCountryPage(routeGroup: string | null) { - selectedLayout.routeGroup = routeGroup; +function renderCountryPage(segments: string[]) { + selectedLayout.segments = segments; render( @@ -46,7 +45,7 @@ describe('CalculatorProviders metadata routes', () => { test('given a v3 workspace page then metadata is fetched from the v3 routes', () => { // When - renderCountryPage('(workspace)'); + renderCountryPage(['(workspace)', 'build']); // Then expect(screen.getByText('Country page')).toBeInTheDocument(); @@ -55,9 +54,21 @@ describe('CalculatorProviders metadata routes', () => { test('given a legacy calculator page then metadata is fetched from v1', () => { // When - renderCountryPage('(calculator)'); + renderCountryPage(['(calculator)', 'reports']); // Then expect(mockUseFetchMetadata).toHaveBeenLastCalledWith('us', 'v1'); }); + + test('given the country index, which only redirects, then no metadata download starts', () => { + renderCountryPage([]); + + expect(mockUseFetchMetadata).toHaveBeenLastCalledWith('us', null); + }); + + test('given the tracker route, which only redirects into v3, then no metadata download starts', () => { + renderCountryPage(['(calculator)', 'tracker']); + + expect(mockUseFetchMetadata).toHaveBeenLastCalledWith('us', null); + }); }); diff --git a/app/src/tests/unit/hooks/useMetadata.test.tsx b/app/src/tests/unit/hooks/useMetadata.test.tsx index bb30f5fcf..c501fad78 100644 --- a/app/src/tests/unit/hooks/useMetadata.test.tsx +++ b/app/src/tests/unit/hooks/useMetadata.test.tsx @@ -235,3 +235,13 @@ describe('useFetchMetadata', () => { }); }); }); + +describe('useFetchMetadata for a page that loads none', () => { + test('given no routes then nothing is fetched', () => { + const { wrapper } = createTestSetup(mockInitialMetadataState); + + renderHook(() => useFetchMetadata(TEST_COUNTRY_US, null), { wrapper }); + + expect(dispatchedThunks).toEqual([]); + }); +}); diff --git a/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts b/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts index fbd76ed19..ec91a44b5 100644 --- a/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts +++ b/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'vitest'; import { + metadataRoutesForPage, metadataRoutesForRouteGroup, V3_API_ROUTES, V3_WORKSPACE_ROUTE_GROUP, @@ -20,4 +21,19 @@ describe('v3ApiRoutes', () => { expect(metadataRoutesForRouteGroup('(calculator)', 'v2')).toBe('v1'); expect(metadataRoutesForRouteGroup(null, 'v2')).toBe('v1'); }); + + test('given a page that only redirects into v3 then, after the switch, no metadata loads', () => { + expect(metadataRoutesForPage([], 'v2')).toBeNull(); + expect(metadataRoutesForPage(['(calculator)', 'tracker'], 'v2')).toBeNull(); + }); + + test('given a page that only redirects, before the switch, then it loads v1 as today', () => { + expect(metadataRoutesForPage([], 'v1')).toBe('v1'); + expect(metadataRoutesForPage(['(calculator)', 'tracker'], 'v1')).toBe('v1'); + }); + + test('given other pages then the route group decides, as before', () => { + expect(metadataRoutesForPage([V3_WORKSPACE_ROUTE_GROUP, 'build'], 'v2')).toBe('v2'); + expect(metadataRoutesForPage(['(calculator)', 'reports'], 'v2')).toBe('v1'); + }); }); diff --git a/calculator-app/src/app/[countryId]/providers.tsx b/calculator-app/src/app/[countryId]/providers.tsx index 307d1bd99..cab79c8d3 100644 --- a/calculator-app/src/app/[countryId]/providers.tsx +++ b/calculator-app/src/app/[countryId]/providers.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import dynamic from "next/dynamic"; -import { useSelectedLayoutSegment } from "next/navigation"; +import { useSelectedLayoutSegments } from "next/navigation"; import { QueryNormalizerProvider } from "@normy/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Provider, useDispatch } from "react-redux"; @@ -11,7 +11,7 @@ import { AppProvider } from "@/contexts/AppContext"; import { CalcOrchestratorProvider } from "@/contexts/CalcOrchestratorContext"; import { useCurrentCountry } from "@/hooks/useCurrentCountry"; import { useFetchMetadata } from "@/hooks/useMetadata"; -import { metadataRoutesForRouteGroup } from "@/libs/flagship/v3ApiRoutes"; +import { metadataRoutesForPage } from "@/libs/flagship/v3ApiRoutes"; import { setCurrentCountry } from "@/reducers/metadataReducer"; import { AppDispatch, store } from "@/store"; import { cacheMonitor } from "@/utils/cacheMonitor"; @@ -90,12 +90,13 @@ export function CalculatorProviders({ /** * Syncs the country to Redux and triggers a non-blocking metadata fetch. * The v3 workspace route group loads from the routes V3_API_ROUTES names; - * the older report builder always loads from v1. + * the older report builder always loads from v1; after the switch, a page + * that only redirects into v3 loads nothing (see metadataRoutesForPage). */ function MetadataBootstrap({ children }: { children: React.ReactNode }) { const countryId = useCurrentCountry(); const dispatch = useDispatch(); - const routes = metadataRoutesForRouteGroup(useSelectedLayoutSegment()); + const routes = metadataRoutesForPage(useSelectedLayoutSegments()); useEffect(() => { dispatch(setCurrentCountry(countryId)); From 5a2eb1e44f9c1207e11c1020e85c785a1b55d48f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:33:41 +0400 Subject: [PATCH 12/28] Hold a folder opened from search on its placeholders until its trail loads The on-demand policy browser treated a level that was still loading like a path the tree does not hold, so a deep folder opened from search showed the top-level grid until every level of its trail had arrived. Now any level of the open folder's trail that is still loading keeps the placeholders up, and only a path that is missing once every level is in falls back to the top level. Co-Authored-By: Claude Opus 5.5 --- .../policyBrowser/V3PolicyBrowser.tsx | 5 +- .../policyBrowser/V3PolicyBrowserV2.test.tsx | 60 ++++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/app/src/components/flagship/policyBrowser/V3PolicyBrowser.tsx b/app/src/components/flagship/policyBrowser/V3PolicyBrowser.tsx index a477201ed..64bf42408 100644 --- a/app/src/components/flagship/policyBrowser/V3PolicyBrowser.tsx +++ b/app/src/components/flagship/policyBrowser/V3PolicyBrowser.tsx @@ -142,8 +142,9 @@ function OnDemandPolicyBrowser({ useEnsureV3Parameters(parameters.map((node) => node.name)); const failed = levels.find((level) => level.isError); - const shownLevels = folderFound ? levels : levels.slice(0, 1); - const loading = policyengineVersion === null || shownLevels.some((level) => level.isPending); + // A folder whose trail is still loading waits for it: only once every + // level is in does a path the tree does not hold fall back to the top. + const loading = policyengineVersion === null || levels.some((level) => level.isPending); return ( void }) { - const [path, setPath] = useState(null); +function Browser({ + onAdd, + initialPath = null, +}: { + onAdd: (path: string) => void; + /** A folder opened from search. */ + initialPath?: string | null; +}) { + const [path, setPath] = useState(initialPath); const valueFor = (parameterPath: string) => { const value = getCurrentValue(store.getState().metadata.parameters[parameterPath]?.values); return value === undefined ? null : formatValue(value, 'currency-USD'); @@ -56,17 +63,24 @@ function Browser({ onAdd }: { onAdd: (path: string) => void }) { describe('V3PolicyBrowser with v3 on the v2 routes', () => { let fetchStub: ReturnType; let releaseFirstLevel: () => void; + let releaseDeepLevel: () => void; beforeEach(async () => { const firstLevel = new Promise((resolve) => { releaseFirstLevel = resolve; }); + const deepLevel = new Promise((resolve) => { + releaseDeepLevel = resolve; + }); fetchStub = stubPublicApiFetch({ [CHILDREN_PATH]: async (url) => { const parentPath = url.searchParams.get('parent_path') ?? ''; if (parentPath === 'gov') { await firstLevel; } + if (parentPath === 'gov.irs.deductions.standard') { + await deepLevel; + } return jsonResponse(childrenPage(parentPath)); }, [PARAMETERS_PATH]: (url) => @@ -101,6 +115,7 @@ describe('V3PolicyBrowser with v3 on the v2 routes', () => { // Given const user = userEvent.setup(); releaseFirstLevel(); + releaseDeepLevel(); render( @@ -159,4 +174,45 @@ describe('V3PolicyBrowser with v3 on the v2 routes', () => { await waitFor(() => expect(onAdd).toHaveBeenCalledWith(CTC_FULLY_REFUNDABLE)); }); + + test('given a deep folder opened from search then placeholders stand for it until its levels arrive', async () => { + // Given — the top level has arrived; a level of the trail is still loading + releaseFirstLevel(); + render( + + + + ); + await waitFor(() => + expect( + requestsTo(fetchStub, CHILDREN_PATH).map((url) => url.searchParams.get('parent_path')) + ).toContain('gov') + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Then — no top-level grid in the meantime + expect(screen.getByRole('status', { name: 'Loading the policy tree' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Open IRS' })).not.toBeInTheDocument(); + + // When + releaseDeepLevel(); + + // Then + expect( + await screen.findByRole('button', { name: 'Add Single to your draft' }) + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Open IRS' })).not.toBeInTheDocument(); + }); + + test('given a folder the tree does not hold then the top level shows', async () => { + releaseFirstLevel(); + releaseDeepLevel(); + render( + + + + ); + + expect(await screen.findByRole('button', { name: 'Open IRS' })).toBeInTheDocument(); + }); }); From fb5ea344056651197cb7076e12c702e3a0ed80bf Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:39:19 +0400 Subject: [PATCH 13/28] Hold the Reforms page's details until their parameters load, and say when they fail On the v2 routes a bill's or a saved reform's parameters load when its detail opens. The bill detail showed its provisions and enabled "Run impact report" and "Open as draft reform" before they arrived, and the saved-reform detail kept its placeholders forever when the load failed. Now, as on the bill's report page, both details show placeholders and keep their actions disabled while the parameters load, and a failed load shows "Could not load the parameters" with a retry. On the v1 routes neither detail waits, as before. Co-Authored-By: Claude Opus 5.5 --- app/src/pages/flagship/Reforms.page.tsx | 274 +++++++++++------- .../unit/pages/flagship/ReformsV2.test.tsx | 116 +++++++- 2 files changed, 277 insertions(+), 113 deletions(-) diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index 739bbb98e..b8fabe810 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -514,6 +514,10 @@ export default function ReformsPage() { [selectedReform, selectedBill] ); const viewedParameters = useEnsureV3Parameters(viewedNames); + // On the v2 routes a detail's actions wait for those parameters, and a + // failed load says so, with a retry. + const parametersWaiting = viewedParameters.waiting; + const parametersFailed = viewedParameters.status === 'error'; const openReform = (reform: Reform) => { // A failed delete or re-run belongs to the reform it was tried on. @@ -641,7 +645,25 @@ export default function ReformsPage() { )} )} - {provisions.length > 0 ? ( + {provisions.length > 0 && parametersFailed ? ( + + ) : provisions.length > 0 && parametersWaiting ? ( + + {provisions.map((provision) => ( +
    + + +
    + ))} +
    + ) : provisions.length > 0 ? ( // A bill's value in today's law has passed, and was scored against the law // before it, so "current law" would mislead; the value reads alone. @@ -685,7 +707,13 @@ export default function ReformsPage() { { region: billRegion(selectedBill, countryId) } ) } - disabled={runReport.isRunning || provisions.length === 0 || scoresOtherChange} + disabled={ + runReport.isRunning || + provisions.length === 0 || + scoresOtherChange || + parametersWaiting || + parametersFailed + } > {runReport.isRunning ? 'Starting report…' : 'Run impact report'} @@ -694,7 +722,12 @@ export default function ReformsPage() { - - ) : ( - 'Loading the parameters to match against…' - )} + Loading the parameters to match against… +

    + )} + {shownMatchPaths.length > 0 && matchParameters.status === 'error' && ( + + )} + {pickError && ( +

    + {pickError}

    )} {turn.chat ? ( - renderChatReply(turn.chat) + renderChatReply(turn.chat, turnIndex) ) : ( {turn.matches.length === 0 ? ( diff --git a/app/src/tests/unit/pages/flagship/AskV2.test.tsx b/app/src/tests/unit/pages/flagship/AskV2.test.tsx index d86b21338..478a4cd88 100644 --- a/app/src/tests/unit/pages/flagship/AskV2.test.tsx +++ b/app/src/tests/unit/pages/flagship/AskV2.test.tsx @@ -1,6 +1,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; -import { render, screen, userEvent, waitFor } from '@test-utils'; +import { render, screen, userEvent, waitFor, within } from '@test-utils'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { AskChatHandlers } from '@/api/askChat'; import { clearDraftReform, getDraftReform } from '@/libs/draftReform'; import AskPage from '@/pages/flagship/Ask.page'; import { clearMetadata } from '@/reducers/metadataReducer'; @@ -8,6 +9,7 @@ import { store } from '@/store'; import { catalogParametersResult } from '@/tests/fixtures/api/publicApi/catalogParametersMocks'; import { jsonResponse, + requestsTo, stubPublicApiFetch, } from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; import { @@ -23,6 +25,16 @@ vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ V3_API_ROUTES: 'v2', })); +const mockStreamAskChatTurn = vi.fn(); +vi.mock('@/api/askChat', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + streamAskChatTurn: (...args: Parameters) => + mockStreamAskChatTurn(...args), + }; +}); + vi.mock('react-router-dom', async () => ({ ...(await vi.importActual('react-router-dom')), useParams: () => ({ countryId: 'us' }), @@ -52,6 +64,33 @@ function answerParameters(url: URL) { }); } +function failParameters() { + return jsonResponse({ status: 'error', message: 'Unavailable' }, 503); +} + +/** The search file answers; the parameters fail as many times as given, then answer. */ +async function stubParametersFailing(failures: number) { + let attempts = 0; + const fetchStub = stubPublicApiFetch({ + [SEARCH_FILE_PATH]: () => jsonResponse(US_SEARCH_FILE), + [PARAMETERS_PATH]: (url) => { + attempts += 1; + return attempts <= failures ? failParameters() : answerParameters(url); + }, + }); + await seedV2ModelCatalog(); + return fetchStub; +} + +async function renderAskWithSearchFile() { + renderAsk(); + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()); +} + +function singleMatch() { + return screen.getByRole('button', { name: /single/i }); +} + describe('AskPage with v3 on the v2 routes', () => { beforeEach(() => { clearDraftReform(); @@ -60,6 +99,7 @@ describe('AskPage with v3 on the v2 routes', () => { afterEach(() => { store.dispatch(clearMetadata()); vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); test('given the search file is still loading then the page says so and a question waits', async () => { @@ -100,4 +140,118 @@ describe('AskPage with v3 on the v2 routes', () => { expect([STANDARD_DEDUCTION_SINGLE, STANDARD_DEDUCTION_JOINT]).toContain(provision.path); expect(provision.baselineValue).toBe(15000); }); + + test('given matches then each waits for its current law, then shows it', async () => { + // Given + let releaseParameters: () => void = () => {}; + const held = new Promise((resolve) => { + releaseParameters = resolve; + }); + stubPublicApiFetch({ + [SEARCH_FILE_PATH]: () => jsonResponse(US_SEARCH_FILE), + [PARAMETERS_PATH]: async (url) => { + await held; + return answerParameters(url); + }, + }); + await seedV2ModelCatalog(); + const user = userEvent.setup(); + await renderAskWithSearchFile(); + + // When + await ask(user, 'standard deduction'); + + // Then + expect(await within(singleMatch()).findByText('current: loading…')).toBeInTheDocument(); + releaseParameters(); + expect(await within(singleMatch()).findByText('current: $15,000')).toBeInTheDocument(); + }); + + test('given matches whose current law fails to load then the page says so and try again loads it', async () => { + // Given + const fetchStub = await stubParametersFailing(1); + const user = userEvent.setup(); + await renderAskWithSearchFile(); + await ask(user, 'standard deduction'); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Could not load current law for these matches' + ); + expect(within(singleMatch()).getByText('current: could not load')).toBeInTheDocument(); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + expect(await within(singleMatch()).findByText('current: $15,000')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(2); + }); + + test('given a picked match whose current law fails to load then it stays out of the draft and the page says so', async () => { + // Given + await stubParametersFailing(Infinity); + const user = userEvent.setup(); + await renderAskWithSearchFile(); + await ask(user, 'standard deduction'); + + // When + await user.click(singleMatch()); + + // Then + expect( + await screen.findByText('Could not load that parameter. Try picking it again.') + ).toBeInTheDocument(); + expect(getDraftReform()?.provisions ?? []).toHaveLength(0); + }); + + test("given the chat's reform whose current law fails to load then no draft step shows and try again brings it", async () => { + // Given + vi.stubEnv('NEXT_PUBLIC_US_ASK', 'on'); + mockStreamAskChatTurn.mockImplementation(async (_request, handlers: AskChatHandlers) => { + handlers.onToolUse?.({ + toolName: 'run_society_simulation', + toolId: 't1', + toolInput: { reform: { [STANDARD_DEDUCTION_SINGLE]: 20000 }, year: 2026 }, + }); + handlers.onDone?.({ content: 'Done.', sessionId: 's1' }); + }); + await stubParametersFailing(1); + const user = userEvent.setup(); + await renderAskWithSearchFile(); + + // When + await ask(user, 'Raise the standard deduction for single filers to $20,000'); + + // Then + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Could not load current law for this reform' + ); + expect(screen.queryByRole('button', { name: /add to draft/i })).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Try again' })); + expect(await screen.findByRole('button', { name: /add to draft/i })).toBeInTheDocument(); + expect(screen.getByText(/\$15,000 → \$20,000/)).toBeInTheDocument(); + }); + + test('given the search file fails to load then the page says so and try again loads it', async () => { + // Given + let attempts = 0; + const fetchStub = stubPublicApiFetch({ + [SEARCH_FILE_PATH]: () => { + attempts += 1; + return attempts === 1 ? failParameters() : jsonResponse(US_SEARCH_FILE); + }, + }); + await seedV2ModelCatalog(); + const user = userEvent.setup(); + renderAsk(); + expect(await screen.findByRole('alert')).toHaveTextContent('Could not load the parameters'); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(requestsTo(fetchStub, SEARCH_FILE_PATH)).toHaveLength(2); + }); }); From 0219e9c73d673f1552b82da746fabe269b82766d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:50:00 +0400 Subject: [PATCH 15/28] Type v2 requests' country from the generated client, and the model catalog's conversion without casts Six v2 requests cast the app's country ID to the countries the Public API serves. They now take it from publicApiCountryId, whose type comes from the generated client and whose list of countries that type checks; a country the v2 routes do not serve throws before any request. The model catalog's conversion keeps only the region types v1 listed through a type guard, so its entries need no cast, and types its entities as the catalog's. The report pages' test reform policy moves to the report policy fixtures. Co-Authored-By: Claude Opus 5.5 --- app/src/api/publicApi/catalogParameters.ts | 9 ++++-- app/src/api/publicApi/client.ts | 19 ++++++++++++ app/src/api/publicApi/modelCatalog.ts | 30 ++++++++++++++----- app/src/api/publicApi/parameterChildren.ts | 9 ++++-- app/src/api/publicApi/searchFile.ts | 9 ++++-- app/src/api/reformStore.ts | 11 +++++-- .../api/publicApi/v2ReportPolicyMocks.ts | 13 ++++++++ .../tests/unit/api/publicApi/client.test.ts | 14 +++++++++ .../unit/pages/flagship/ReportV2.test.tsx | 13 +------- 9 files changed, 99 insertions(+), 28 deletions(-) diff --git a/app/src/api/publicApi/catalogParameters.ts b/app/src/api/publicApi/catalogParameters.ts index 2bd57b6c2..8a7bc6e98 100644 --- a/app/src/api/publicApi/catalogParameters.ts +++ b/app/src/api/publicApi/catalogParameters.ts @@ -1,4 +1,9 @@ -import { createPublicApiClient, requireResult, type PublicApiClient } from '@/api/publicApi/client'; +import { + createPublicApiClient, + publicApiCountryId, + requireResult, + type PublicApiClient, +} from '@/api/publicApi/client'; import type { components } from '@/api/publicApi/schema'; import { v1FallbackLabel } from '@/libs/flagship/v2ParameterSearch'; import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; @@ -84,7 +89,7 @@ export async function fetchCatalogParameters( client.GET('/v2/model-catalog/parameters', { params: { query: { - country_id: countryId as 'us' | 'uk', + country_id: publicApiCountryId(countryId), policyengine_version: policyengineVersion, name: batch, }, diff --git a/app/src/api/publicApi/client.ts b/app/src/api/publicApi/client.ts index 0a40dded4..05c78968c 100644 --- a/app/src/api/publicApi/client.ts +++ b/app/src/api/publicApi/client.ts @@ -16,6 +16,25 @@ import type { paths } from './schema'; */ export type PublicApiClient = Client; +/** A country the v2 routes serve, as the generated client types it. */ +export type PublicApiCountryId = + paths['/v2/model-catalog']['get']['parameters']['query']['country_id']; + +/** Every country the v2 routes serve: the type requires each one, and no other. */ +const PUBLIC_API_COUNTRIES: Record = { us: true, uk: true }; + +function isPublicApiCountryId(countryId: string): countryId is PublicApiCountryId { + return Object.hasOwn(PUBLIC_API_COUNTRIES, countryId); +} + +/** The app's country ID for a v2 request; one the v2 routes do not serve throws. */ +export function publicApiCountryId(countryId: string): PublicApiCountryId { + if (!isPublicApiCountryId(countryId)) { + throw new Error(`The Public API does not serve the country "${countryId}".`); + } + return countryId; +} + export interface CreatePublicApiClientOptions { /** Who requests are made as; metadata routes need no one. */ credentials?: V2Credentials; diff --git a/app/src/api/publicApi/modelCatalog.ts b/app/src/api/publicApi/modelCatalog.ts index a48a850e6..5f0137795 100644 --- a/app/src/api/publicApi/modelCatalog.ts +++ b/app/src/api/publicApi/modelCatalog.ts @@ -1,4 +1,9 @@ -import { createPublicApiClient, requireResult, type PublicApiClient } from '@/api/publicApi/client'; +import { + createPublicApiClient, + publicApiCountryId, + requireResult, + type PublicApiClient, +} from '@/api/publicApi/client'; import type { components } from '@/api/publicApi/schema'; import type { MetadataApiPayload, MetadataRegionEntry } from '@/types/metadata'; @@ -18,12 +23,21 @@ import type { MetadataApiPayload, MetadataRegionEntry } from '@/types/metadata'; type Schemas = components['schemas']; +type RegionOption = Schemas['MetadataRegionOption']; +type V1RegionType = MetadataRegionEntry['type']; + /** Region types v1 listed per country; v2 also lists places, which v3 never offered. */ -const V1_REGION_TYPES: Record = { +const V1_REGION_TYPES: Record = { us: ['national', 'state', 'congressional_district'], uk: ['national', 'country', 'constituency', 'local_authority'], }; +/** Whether v1 listed a region of this option's type, for a country offering `offered`. */ +function offeredByV1(offered: readonly V1RegionType[]) { + return (option: RegionOption): option is RegionOption & { type: V1RegionType } => + offered.some((type) => type === option.type); +} + function regionEntries( countryId: string, catalog: Schemas['MetadataModelCatalogResult'] @@ -31,8 +45,8 @@ function regionEntries( const offered = V1_REGION_TYPES[countryId] ?? ['national']; const regionsByCode = new Map(catalog.regions.map((region) => [region.code, region])); return catalog.economy_options.region - .filter((option) => offered.includes(option.type)) - .map((option) => { + .filter(offeredByV1(offered)) + .map((option): MetadataRegionEntry => { const region = regionsByCode.get(option.name); return { name: option.name, @@ -42,11 +56,13 @@ function regionEntries( ...(option.type === 'congressional_district' && region?.state_code ? { state_abbreviation: region.state_code, state_name: region.state_name ?? '' } : {}), - } as MetadataRegionEntry; + }; }); } -function entityCollection(entities: Schemas['MetadataEntity'][]): Record { +function entityCollection( + entities: Schemas['MetadataEntity'][] +): Record { return Object.fromEntries( entities.map((entity) => [ entity.key, @@ -62,7 +78,7 @@ export async function fetchV2ModelCatalogMetadata( ): Promise { const catalog = await requireResult( client.GET('/v2/model-catalog', { - params: { query: { country_id: countryId as 'us' | 'uk' } }, + params: { query: { country_id: publicApiCountryId(countryId) } }, }) ); diff --git a/app/src/api/publicApi/parameterChildren.ts b/app/src/api/publicApi/parameterChildren.ts index 310a8e122..7e8bbdc4b 100644 --- a/app/src/api/publicApi/parameterChildren.ts +++ b/app/src/api/publicApi/parameterChildren.ts @@ -1,4 +1,9 @@ -import { createPublicApiClient, requireResult, type PublicApiClient } from '@/api/publicApi/client'; +import { + createPublicApiClient, + publicApiCountryId, + requireResult, + type PublicApiClient, +} from '@/api/publicApi/client'; import type { components } from '@/api/publicApi/schema'; /** @@ -26,7 +31,7 @@ export async function fetchParameterChildren( client.GET('/v2/parameters/children', { params: { query: { - country_id: countryId as 'us' | 'uk', + country_id: publicApiCountryId(countryId), policyengine_version: policyengineVersion, parent_path: parentPath, reform_changeable: true, diff --git a/app/src/api/publicApi/searchFile.ts b/app/src/api/publicApi/searchFile.ts index 0f6310eba..04db35901 100644 --- a/app/src/api/publicApi/searchFile.ts +++ b/app/src/api/publicApi/searchFile.ts @@ -1,4 +1,9 @@ -import { createPublicApiClient, requireBody, type PublicApiClient } from '@/api/publicApi/client'; +import { + createPublicApiClient, + publicApiCountryId, + requireBody, + type PublicApiClient, +} from '@/api/publicApi/client'; import type { components } from '@/api/publicApi/schema'; /** @@ -29,7 +34,7 @@ export async function fetchParameterSearchFile( client.GET('/v2/parameters/search-file', { params: { query: { - country_id: countryId as 'us' | 'uk', + country_id: publicApiCountryId(countryId), policyengine_version: policyengineVersion, }, }, diff --git a/app/src/api/reformStore.ts b/app/src/api/reformStore.ts index 0930663da..6a0a1173f 100644 --- a/app/src/api/reformStore.ts +++ b/app/src/api/reformStore.ts @@ -1,6 +1,11 @@ import { convertDateRangeMapToValueIntervals } from '@/adapters/conversionHelpers'; import { fetchPolicyById } from '@/api/policy'; -import { createPublicApiClient, requireResult, type PublicApiClient } from '@/api/publicApi/client'; +import { + createPublicApiClient, + publicApiCountryId, + requireResult, + type PublicApiClient, +} from '@/api/publicApi/client'; import type { components } from '@/api/publicApi/schema'; import { AccountAssociationRoutes, @@ -450,7 +455,7 @@ export function v2ReformPolicyBody( metadata?: ParameterMetadataCollection ): V2PolicyCreateBody { return { - country_id: countryId as V2PolicyCreateBody['country_id'], + country_id: publicApiCountryId(countryId), tax_benefit_model_id: catalog.taxBenefitModelId, parameter_values: parameters.flatMap((parameter) => { const parameterId = parameterIds[parameter.name]; @@ -519,7 +524,7 @@ export async function fetchV2PolicyItem( client.GET('/v2/policies/{policy_id}', { params: { path: { policy_id: policyId }, - query: { country_id: countryId as V2PolicyCreateBody['country_id'] }, + query: { country_id: publicApiCountryId(countryId) }, }, }) ); diff --git a/app/src/tests/fixtures/api/publicApi/v2ReportPolicyMocks.ts b/app/src/tests/fixtures/api/publicApi/v2ReportPolicyMocks.ts index d00a3b34b..ab233d05a 100644 --- a/app/src/tests/fixtures/api/publicApi/v2ReportPolicyMocks.ts +++ b/app/src/tests/fixtures/api/publicApi/v2ReportPolicyMocks.ts @@ -2,6 +2,7 @@ import { STANDARD_DEDUCTION_JOINT, STANDARD_DEDUCTION_SINGLE, } from '@/tests/fixtures/api/publicApi/searchFileMocks'; +import type { Policy } from '@/types/ingredients/Policy'; import type { PolicyMetadata } from '@/types/metadata/policyMetadata'; /** A report's reform policy as v1 stores it: a larger single standard deduction. */ @@ -13,6 +14,18 @@ export const REFORM_POLICY_METADATA: PolicyMetadata = { policy_hash: 'reform-hash', }; +/** The same reform policy, as the report hooks hand it to the page once parsed. */ +export const REFORM_POLICY: Policy = { + id: '98557', + countryId: 'us', + parameters: [ + { + name: STANDARD_DEDUCTION_SINGLE, + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 20000 }], + }, + ], +}; + /** A saved reform the report is compared with: a larger joint standard deduction. */ export const SAVED_BASELINE_POLICY_METADATA: PolicyMetadata = { id: '77001', diff --git a/app/src/tests/unit/api/publicApi/client.test.ts b/app/src/tests/unit/api/publicApi/client.test.ts index 88fba9d10..e552aa8d6 100644 --- a/app/src/tests/unit/api/publicApi/client.test.ts +++ b/app/src/tests/unit/api/publicApi/client.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, vi } from 'vitest'; import { createPublicApiClient, + publicApiCountryId, requireResult, type CreatePublicApiClientOptions, } from '@/api/publicApi/client'; @@ -133,3 +134,16 @@ describe('requireResult', () => { await expect(call).rejects.toSatisfy((error: unknown) => isV2ApiError(error, 0)); }); }); + +describe('publicApiCountryId', () => { + test('given a country the v2 routes serve then it is returned as their country type', () => { + expect(publicApiCountryId('us')).toBe('us'); + expect(publicApiCountryId('uk')).toBe('uk'); + }); + + test('given a country the v2 routes do not serve then it throws before any request', () => { + expect(() => publicApiCountryId('ca')).toThrow( + 'The Public API does not serve the country "ca"' + ); + }); +}); diff --git a/app/src/tests/unit/pages/flagship/ReportV2.test.tsx b/app/src/tests/unit/pages/flagship/ReportV2.test.tsx index 92fdc9f63..811c7b749 100644 --- a/app/src/tests/unit/pages/flagship/ReportV2.test.tsx +++ b/app/src/tests/unit/pages/flagship/ReportV2.test.tsx @@ -12,6 +12,7 @@ import { stubPublicApiFetch, } from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; import { STANDARD_DEDUCTION_SINGLE } from '@/tests/fixtures/api/publicApi/searchFileMocks'; +import { REFORM_POLICY } from '@/tests/fixtures/api/publicApi/v2ReportPolicyMocks'; import { EXISTING_REPORT, EXISTING_REPORT_ID, @@ -19,7 +20,6 @@ import { } from '@/tests/fixtures/hooks/useFlagshipReportMocks'; import { seedV2ModelCatalog } from '@/tests/fixtures/hooks/v3MetadataWrapper'; import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; -import type { Policy } from '@/types/ingredients/Policy'; const mockUseFlagshipReport = vi.fn(); const mockRetry = vi.fn(); @@ -92,17 +92,6 @@ const COMPLETE_STATUS = { }; /** The report's reform policy, as v1 stores it: a larger standard deduction. */ -const REFORM_POLICY: Policy = { - id: '98557', - countryId: 'us', - parameters: [ - { - name: STANDARD_DEDUCTION_SINGLE, - values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 20000 }], - }, - ], -}; - function answerParameters(url: URL) { return jsonResponse({ status: 'ok', From 337576280f79fb1bfff4e63390331c00e2f7a8bd Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:16:06 +0400 Subject: [PATCH 16/28] Load no metadata on every page that only redirects into v3, after the switch Besides the country index and the old bill tracker, three more pages only send the browser on into v3: the old Home launcher (to Build), the old Library (to Reforms) and the paused Ask page (to Build). After the switch they now load nothing, like the other two, and the page each one lands on loads its own metadata. Before the switch they load as they do today. A page that redirects into the older report builder keeps v1. Co-Authored-By: Claude Opus 5.5 --- app/src/docs/ARCHITECTURE-COUNTRY-ID.md | 2 +- app/src/libs/flagship/v3ApiRoutes.ts | 17 +++++++-- .../calculator/CalculatorProviders.test.tsx | 9 +++-- .../unit/libs/flagship/v3ApiRoutes.test.ts | 35 +++++++++++++++---- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/app/src/docs/ARCHITECTURE-COUNTRY-ID.md b/app/src/docs/ARCHITECTURE-COUNTRY-ID.md index e32878d5b..e6824ad65 100644 --- a/app/src/docs/ARCHITECTURE-COUNTRY-ID.md +++ b/app/src/docs/ARCHITECTURE-COUNTRY-ID.md @@ -43,7 +43,7 @@ Components read the country from this hook, not from Redux. `MetadataBootstrap` in `providers.tsx` copies the country into Redux with `setCurrentCountry(countryId)` and calls `useFetchMetadata(countryId, routes)`. Loading is non-blocking: pages render while metadata loads. -`routes` comes from `metadataRoutesForPage(useSelectedLayoutSegments())` (`src/libs/flagship/v3ApiRoutes.ts`): `'v1'` (`GET /{country}/metadata`, the whole model) for the older report builder, the routes `V3_API_ROUTES` names for the v3 workspace (`'v2'` loads only the small `GET /v2/model-catalog`, and v3 loads parameters on demand), and, once `V3_API_ROUTES` is `'v2'`, `null` (load nothing) for pages that only redirect into v3 (the country index and the tracker's old address). +`routes` comes from `metadataRoutesForPage(useSelectedLayoutSegments())` (`src/libs/flagship/v3ApiRoutes.ts`): `'v1'` (`GET /{country}/metadata`, the whole model) for the older report builder, the routes `V3_API_ROUTES` names for the v3 workspace (`'v2'` loads only the small `GET /v2/model-catalog`, and v3 loads parameters on demand), and, once `V3_API_ROUTES` is `'v2'`, `null` (load nothing) for pages that only redirect into v3: the country index, the old Home launcher (`(calculator)/home`, to Build), the old Library and bill tracker (`(calculator)/library` and `(calculator)/tracker`, to Reforms) and the paused Ask page (`(workspace)/ask`, to Build). The page each one lands on loads its own metadata. `useFetchMetadata` (`src/hooks/useMetadata.ts`) dispatches `fetchMetadataThunk(metadataRequest(countryId, routes))` only when there is no metadata yet, it belongs to another country, or it came from other routes, so moving between pages of one country does not refetch. Hooks that need metadata for the current country compare `metadata.currentCountry` with the country they were given. diff --git a/app/src/libs/flagship/v3ApiRoutes.ts b/app/src/libs/flagship/v3ApiRoutes.ts index 3ae48116d..dfaaae401 100644 --- a/app/src/libs/flagship/v3ApiRoutes.ts +++ b/app/src/libs/flagship/v3ApiRoutes.ts @@ -37,10 +37,21 @@ export function metadataRoutesForRouteGroup( } /** - * Pages that only send the browser on into v3 (the country's index, and - * the bill tracker's old address), by their segments below `[countryId]`. + * Pages that only send the browser on into v3, by their segments below + * `[countryId]`: the country's index (to Build, or to the older report + * builder when the v3 shell is off, which loads its own metadata), the old + * Home launcher (to Build), the old Library and bill tracker (to Reforms), + * and the paused Ask page (to Build). A page that redirects elsewhere, such + * as `(calculator)/reports/create/[userReportId]` into the older report + * builder, is not one of them. */ -const REDIRECT_ONLY_PAGES: readonly (readonly string[])[] = [[], ['(calculator)', 'tracker']]; +const REDIRECT_ONLY_PAGES: readonly (readonly string[])[] = [ + [], + ['(calculator)', 'home'], + ['(calculator)', 'library'], + ['(calculator)', 'tracker'], + [V3_WORKSPACE_ROUTE_GROUP, 'ask'], +]; /** * Where model metadata loads from for a page, by its segments below diff --git a/app/src/tests/unit/calculator/CalculatorProviders.test.tsx b/app/src/tests/unit/calculator/CalculatorProviders.test.tsx index f50dd5d1e..85662f07a 100644 --- a/app/src/tests/unit/calculator/CalculatorProviders.test.tsx +++ b/app/src/tests/unit/calculator/CalculatorProviders.test.tsx @@ -66,8 +66,13 @@ describe('CalculatorProviders metadata routes', () => { expect(mockUseFetchMetadata).toHaveBeenLastCalledWith('us', null); }); - test('given the tracker route, which only redirects into v3, then no metadata download starts', () => { - renderCountryPage(['(calculator)', 'tracker']); + test.each([ + ['the old Home launcher, sent on to Build', ['(calculator)', 'home']], + ['the old Library, sent on to Reforms', ['(calculator)', 'library']], + ['the old bill tracker, sent on to Reforms', ['(calculator)', 'tracker']], + ['the paused Ask page, sent on to Build', ['(workspace)', 'ask']], + ])('given %s, which only redirects into v3, then no metadata download starts', (_, segments) => { + renderCountryPage(segments); expect(mockUseFetchMetadata).toHaveBeenLastCalledWith('us', null); }); diff --git a/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts b/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts index ec91a44b5..1a647ca27 100644 --- a/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts +++ b/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts @@ -22,14 +22,35 @@ describe('v3ApiRoutes', () => { expect(metadataRoutesForRouteGroup(null, 'v2')).toBe('v1'); }); - test('given a page that only redirects into v3 then, after the switch, no metadata loads', () => { - expect(metadataRoutesForPage([], 'v2')).toBeNull(); - expect(metadataRoutesForPage(['(calculator)', 'tracker'], 'v2')).toBeNull(); - }); + /** Every page below `[countryId]` that only redirects into v3, by its segments. */ + const REDIRECTS_INTO_V3: [string, string[]][] = [ + ['the country index', []], + ['(calculator)/home', ['(calculator)', 'home']], + ['(calculator)/library', ['(calculator)', 'library']], + ['(calculator)/tracker', ['(calculator)', 'tracker']], + ['(workspace)/ask', [V3_WORKSPACE_ROUTE_GROUP, 'ask']], + ]; + + test.each(REDIRECTS_INTO_V3)( + 'given %s, which only redirects into v3, then after the switch no metadata loads', + (_, segments) => { + expect(metadataRoutesForPage(segments, 'v2')).toBeNull(); + } + ); + + test.each(REDIRECTS_INTO_V3)( + 'given %s, which only redirects, before the switch, then it loads as today', + (_, segments) => { + expect(metadataRoutesForPage(segments, 'v1')).toBe( + metadataRoutesForRouteGroup(segments[0] ?? null, 'v1') + ); + } + ); - test('given a page that only redirects, before the switch, then it loads v1 as today', () => { - expect(metadataRoutesForPage([], 'v1')).toBe('v1'); - expect(metadataRoutesForPage(['(calculator)', 'tracker'], 'v1')).toBe('v1'); + test('given a page that redirects into the older report builder then it keeps v1', () => { + expect( + metadataRoutesForPage(['(calculator)', 'reports', 'create', '[userReportId]'], 'v2') + ).toBe('v1'); }); test('given other pages then the route group decides, as before', () => { From 76ddbd13f363a4818dd3dab84ba5a2780d02a69b Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:22:58 +0400 Subject: [PATCH 17/28] Report a report's failed parameter load with a retry, and keep the v1 report as it was MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the v2 routes, when the parameters a report's policies name failed to load (or the model catalog had failed), no policy was parsed, none was listed as failed and nothing was loading, so the Report page said "Loading report…" for good and the household breakdowns kept their spinner. Such a policy is now listed as failed: the Report page shows "Could not load the reform policy" with a retry, the household breakdowns show their error, and retrying asks for the parameters again. A policy that names no parameter (current law) parses regardless. The v2 hook memoises what it parses on the stored policies and the loaded parameters, so the policies keep their identity across renders. On the v1 routes the report and household hooks read and parse the policies as they did before v3 loaded parameters on demand: once the whole metadata is in, without waiting for it. Co-Authored-By: Claude Opus 5.5 --- app/src/hooks/useFlagshipReport.ts | 2 +- app/src/hooks/useV3Policies.ts | 135 +++++++++++++----- app/src/pages/flagship/Report.page.tsx | 13 +- .../report/HouseholdBreakdowns.test.tsx | 4 +- .../unit/hooks/useFlagshipReport.test.tsx | 16 +++ .../unit/hooks/useFlagshipReportV2.test.tsx | 100 +++++++++++-- .../unit/pages/flagship/ReportV2.test.tsx | 1 - .../pages/flagship/ReportV2Policies.test.tsx | 132 +++++++++++++++++ 8 files changed, 354 insertions(+), 49 deletions(-) create mode 100644 app/src/tests/unit/pages/flagship/ReportV2Policies.test.tsx diff --git a/app/src/hooks/useFlagshipReport.ts b/app/src/hooks/useFlagshipReport.ts index f9067d719..6f96a7e8c 100644 --- a/app/src/hooks/useFlagshipReport.ts +++ b/app/src/hooks/useFlagshipReport.ts @@ -122,7 +122,7 @@ export function useFlagshipReport(id: string): FlagshipReportData { Promise.all([ reportError ? refetchReport() : null, ...simulationResults.queries.filter((query) => query.error).map((query) => query.refetch()), - failedPolicyIds.length > 0 ? policyResults.retry() : null, + policyResults.retry(), ]), }; } diff --git a/app/src/hooks/useV3Policies.ts b/app/src/hooks/useV3Policies.ts index 336099e60..7c6feae50 100644 --- a/app/src/hooks/useV3Policies.ts +++ b/app/src/hooks/useV3Policies.ts @@ -1,8 +1,12 @@ +import { useMemo } from 'react'; +import { useQueries, type QueryObserverResult } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import { PolicyAdapter } from '@/adapters'; import { fetchPolicyById } from '@/api/policy'; +import { useParameterMetadata } from '@/hooks/useParameterMetadata'; import { useEnsureV3Parameters } from '@/hooks/useV3Parameters'; import { useParallelQueries } from '@/hooks/utils/queryUtils'; +import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; import { policyKeys } from '@/libs/queryKeys'; import type { RootState } from '@/store'; import type { Policy } from '@/types/ingredients/Policy'; @@ -11,22 +15,70 @@ import type { PolicyMetadata } from '@/types/metadata/policyMetadata'; /** * The policies a v3 report shows, in the app's model. A policy is parsed * against the metadata of the parameters it names (which types its - * values), so each is first read as stored (`GET /{country}/policy/{id}`), - * then the parameters it names are made sure of, then it is parsed. On the - * v1 routes the whole metadata holds every parameter; on the v2 routes the - * named ones load in one request. A policy that cannot be read or parsed is + * values). On the v1 routes each policy is read and parsed once the whole + * metadata is in, as before v3 loaded parameters on demand. On the v2 + * routes each is first read as stored (`GET /{country}/policy/{id}`), then + * the parameters it names load in one request, then it is parsed. A policy + * that cannot be read or parsed, or whose parameters fail to load, is * listed as failed. */ export interface V3Policies { policies: Policy[]; /** The policies that failed to load or parse, with nothing to show. */ failedPolicyIds: string[]; - /** Waiting for a policy, or for the parameters it names. */ + /** Waiting for a policy, or (on the v2 routes) for the parameters it names. */ isLoading: boolean; /** Asks again for whatever failed. */ retry: () => Promise; } +function useV1Policies(countryId: string, policyIds: string[], enabled: boolean): V3Policies { + const { parameters, isReady } = useParameterMetadata(countryId); + const results = useParallelQueries(policyIds, { + queryKey: policyKeys.byId, + queryFn: async (policyId) => + PolicyAdapter.fromMetadata(await fetchPolicyById(countryId, policyId), parameters), + enabled: enabled && isReady && policyIds.length > 0, + staleTime: 5 * 60 * 1000, + structuralSharing: false, + }); + return { + policies: results.queries + .map((query) => query.data) + .filter((policy): policy is Policy => !!policy), + failedPolicyIds: policyIds.filter((_, index) => { + const query = results.queries[index]; + return !!query?.error && !query.data; + }), + isLoading: results.isLoading, + retry: () => + Promise.all(results.queries.filter((query) => query.error).map((query) => query.refetch())), + }; +} + +interface StoredPolicies { + data: (PolicyMetadata | undefined)[]; + /** Read and failed, with nothing to show. */ + failed: boolean[]; + isLoading: boolean; + refetchFailed: () => Promise; +} + +/** + * The stored policies' results, kept by the query library while nothing + * changes (it compares this result deeply), so what is parsed from them + * keeps its identity from render to render. + */ +function combineStoredPolicies(results: QueryObserverResult[]): StoredPolicies { + return { + data: results.map((result) => result.data), + failed: results.map((result) => !!result.error && !result.data), + isLoading: results.some((result) => result.isLoading), + refetchFailed: () => + Promise.all(results.filter((result) => result.error).map((result) => result.refetch())), + }; +} + function parse(stored: PolicyMetadata, parameters: RootState['metadata']['parameters']) { try { return PolicyAdapter.fromMetadata(stored, parameters); @@ -35,46 +87,59 @@ function parse(stored: PolicyMetadata, parameters: RootState['metadata']['parame } } -export function useV3Policies( - countryId: string, - policyIds: string[], - enabled: boolean -): V3Policies { - const stored = useParallelQueries(policyIds, { - queryKey: policyKeys.storedById, - queryFn: (policyId) => fetchPolicyById(countryId, policyId), - enabled: enabled && policyIds.length > 0, - staleTime: 5 * 60 * 1000, +function namedParameters(stored: PolicyMetadata | undefined): string[] { + return stored ? Object.keys(stored.policy_json ?? {}) : []; +} + +function useV2Policies(countryId: string, policyIds: string[], enabled: boolean): V3Policies { + const uniqueIds = [...new Set(policyIds)]; + const stored = useQueries({ + queries: uniqueIds.map((policyId) => ({ + queryKey: policyKeys.storedById(policyId), + queryFn: () => fetchPolicyById(countryId, policyId), + enabled, + staleTime: 5 * 60 * 1000, + })), + combine: combineStoredPolicies, }); - const storedPolicies = stored.queries.map((query) => query.data); - // A report holds two or three policies, so naming and parsing them on - // each render costs little; the parameter load compares names by content. - const names = [ - ...new Set( - storedPolicies.flatMap((policy) => (policy ? Object.keys(policy.policy_json ?? {}) : [])) - ), - ]; + const names = [...new Set(stored.data.flatMap(namedParameters))]; const parameterStatus = useEnsureV3Parameters(names); const parameters = useSelector((state: RootState) => state.metadata.parameters); const ready = parameterStatus.status === 'ready'; - const parsed = storedPolicies.map((policy) => - policy && ready ? parse(policy, parameters) : undefined + const parametersFailed = parameterStatus.status === 'error'; + + // Undefined: not parsed yet; null: failed. A policy that names no + // parameter (current law) needs none of them loaded. + const parsed = useMemo( + () => + stored.data.map((policy) => + policy && (ready || namedParameters(policy).length === 0) + ? parse(policy, parameters) + : undefined + ), + [stored.data, ready, parameters] ); + const policies = useMemo(() => parsed.filter((policy): policy is Policy => !!policy), [parsed]); - const failedPolicyIds = policyIds.filter((_, index) => { - const query = stored.queries[index]; - return (!!query?.error && !query.data) || parsed[index] === null; - }); - const policies = parsed.filter((policy): policy is Policy => !!policy); - const waitingForParameters = storedPolicies.some(Boolean) && parameterStatus.status === 'loading'; + const failedPolicyIds = uniqueIds.filter( + (_, index) => + stored.failed[index] || + parsed[index] === null || + (parametersFailed && !!stored.data[index] && parsed[index] === undefined) + ); + const waitingForParameters = parameterStatus.waiting && stored.data.some(Boolean); return { policies, failedPolicyIds, isLoading: enabled && (stored.isLoading || waitingForParameters), retry: () => - Promise.all([ - ...stored.queries.filter((query) => query.error).map((query) => query.refetch()), - parameterStatus.status === 'error' ? parameterStatus.retry() : null, - ]), + Promise.all([stored.refetchFailed(), parametersFailed ? parameterStatus.retry() : null]), }; } + +/** The report's policies for the routes v3 uses (V3_API_ROUTES, a constant). */ +export const useV3Policies: ( + countryId: string, + policyIds: string[], + enabled: boolean +) => V3Policies = V3_API_ROUTES === 'v2' ? useV2Policies : useV1Policies; diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 6b82f74e6..3c6d8484c 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -193,11 +193,12 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep // (it waits for the parameter metadata, so names and current law come // with it): the report waits for them rather than show an empty reform // to adjust. A policy or metadata that failed lets it show without them. + const reformPolicyFailed = !!reformPolicyId && failedPolicyIds.includes(reformPolicyId); const awaitingProvisions = !meta && !unresolvable && !!reformPolicyId && - !failedPolicyIds.includes(reformPolicyId) && + !reformPolicyFailed && metadata.status !== 'error'; // A household report is the classic calculator's: this page shows @@ -553,6 +554,16 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep )} + {!meta && reformPolicyFailed && ( + + + + )} {baselineUnknown && ( ({ fetchPolicyById: (...args: unknown[]) => mockFetchPolicyById(...args), })); // The model's parameters, loaded: the policies parse at once. -vi.mock('@/hooks/useV3Parameters', () => ({ - useEnsureV3Parameters: () => ({ status: 'ready', waiting: false, message: null, retry: vi.fn() }), +vi.mock('@/hooks/useParameterMetadata', () => ({ + useParameterMetadata: () => ({ parameters: {}, isReady: true }), })); // The variation request is the v2 hook's own, tested on its own: here it // records what the v2 pages ask for, and stays loading. diff --git a/app/src/tests/unit/hooks/useFlagshipReport.test.tsx b/app/src/tests/unit/hooks/useFlagshipReport.test.tsx index 200b272c2..489422f3c 100644 --- a/app/src/tests/unit/hooks/useFlagshipReport.test.tsx +++ b/app/src/tests/unit/hooks/useFlagshipReport.test.tsx @@ -64,6 +64,7 @@ function renderReportHook(id: string) { describe('useFlagshipReport', () => { beforeEach(() => { vi.clearAllMocks(); + mockFetchPolicyById.mockReset(); store.dispatch(clearMetadata()); mockFetchReportById.mockResolvedValue(EXISTING_REPORT_METADATA); mockFetchSimulationById.mockImplementation(async (_country: string, id: string) => @@ -71,6 +72,21 @@ describe('useFlagshipReport', () => { ); }); + test('given the whole metadata is not in yet then the report is not held up by its policies', async () => { + // Given: v1's metadata has not loaded (beforeEach clears it). + mockFetchPolicyById.mockResolvedValue({ id: '98557', country_id: 'us', policy_json: {} }); + + // When + const { result } = renderReportHook(EXISTING_REPORT_ID); + + // Then + await waitFor(() => expect(result.current.simulations).toHaveLength(2)); + expect(result.current.isLoading).toBe(false); + expect(result.current.policies).toEqual([]); + // As before v3 loaded parameters on demand, a policy is read once the metadata is in. + expect(mockFetchPolicyById).not.toHaveBeenCalled(); + }); + test('given a link to a report then it loads with no error', async () => { const { result } = renderReportHook(EXISTING_REPORT_ID); diff --git a/app/src/tests/unit/hooks/useFlagshipReportV2.test.tsx b/app/src/tests/unit/hooks/useFlagshipReportV2.test.tsx index db4a125a7..00b54dc77 100644 --- a/app/src/tests/unit/hooks/useFlagshipReportV2.test.tsx +++ b/app/src/tests/unit/hooks/useFlagshipReportV2.test.tsx @@ -1,5 +1,6 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { useFlagshipHouseholdPolicies } from '@/hooks/useFlagshipHousehold'; import { useFlagshipReport } from '@/hooks/useFlagshipReport'; import { clearMetadata } from '@/reducers/metadataReducer'; import { store } from '@/store'; @@ -58,18 +59,32 @@ vi.mock('@/api/policy', async (importOriginal) => ({ const PARAMETERS_PATH = '/v2/model-catalog/parameters'; +function answerParameters(url: URL) { + return jsonResponse({ + status: 'ok', + message: null, + result: catalogParametersResult(url.searchParams.getAll('name')), + }); +} + +/** The parameters request fails the first time, then answers. */ +function stubParametersFailingOnce() { + let attempts = 0; + return stubPublicApiFetch({ + [PARAMETERS_PATH]: (url) => { + attempts += 1; + return attempts === 1 + ? jsonResponse({ status: 'error', message: 'Unavailable' }, 503) + : answerParameters(url); + }, + }); +} + describe('useFlagshipReport with v3 on the v2 routes', () => { let fetchStub: ReturnType; beforeEach(async () => { - fetchStub = stubPublicApiFetch({ - [PARAMETERS_PATH]: (url) => - jsonResponse({ - status: 'ok', - message: null, - result: catalogParametersResult(url.searchParams.getAll('name')), - }), - }); + fetchStub = stubPublicApiFetch({ [PARAMETERS_PATH]: answerParameters }); await seedV2ModelCatalog(); }); @@ -123,4 +138,71 @@ describe('useFlagshipReport with v3 on the v2 routes', () => { ); expect(baseline?.parameters?.[0].name).toBe(STANDARD_DEDUCTION_JOINT); }); + + test('given the parameters a policy names fail to load then it is failed, not loading, and retry parses it', async () => { + // Given + fetchStub = stubParametersFailingOnce(); + const { result } = renderHook(() => useFlagshipReport(EXISTING_REPORT_ID), { + wrapper: v3Wrapper(), + }); + await waitFor(() => + expect(result.current.failedPolicyIds).toEqual([REFORM_POLICY_METADATA.id]) + ); + expect(result.current.isLoading).toBe(false); + // Current law names no parameter, so it parses regardless. + expect(result.current.policies.map((policy) => policy.id)).toEqual([ + CURRENT_LAW_POLICY_METADATA.id, + ]); + + // When + await act(() => result.current.retry()); + + // Then + await waitFor(() => expect(result.current.policies).toHaveLength(2)); + expect(result.current.failedPolicyIds).toEqual([]); + expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(2); + }); + + test('given the policies are in then another render keeps the same policies', async () => { + // Given + const { result, rerender } = renderHook(() => useFlagshipReport(EXISTING_REPORT_ID), { + wrapper: v3Wrapper(), + }); + await waitFor(() => expect(result.current.policies).toHaveLength(2)); + const policies = result.current.policies; + + // When + rerender(); + + // Then + expect(result.current.policies).toBe(policies); + }); +}); + +describe('useFlagshipHouseholdPolicies with v3 on the v2 routes', () => { + const policyIds = [CURRENT_LAW_POLICY_METADATA.id, REFORM_POLICY_METADATA.id]; + + afterEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + test('given the parameters a policy names fail to load then it reports an error, and retry loads it', async () => { + // Given + stubParametersFailingOnce(); + await seedV2ModelCatalog(); + const { result } = renderHook( + () => useFlagshipHouseholdPolicies({ countryId: 'us', policyIds, enabled: true }), + { wrapper: v3Wrapper() } + ); + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.isLoading).toBe(false); + + // When + await act(() => result.current.retry()); + + // Then + await waitFor(() => expect(result.current.policies).toHaveLength(2)); + expect(result.current.error).toBeNull(); + }); }); diff --git a/app/src/tests/unit/pages/flagship/ReportV2.test.tsx b/app/src/tests/unit/pages/flagship/ReportV2.test.tsx index 811c7b749..53bae25fc 100644 --- a/app/src/tests/unit/pages/flagship/ReportV2.test.tsx +++ b/app/src/tests/unit/pages/flagship/ReportV2.test.tsx @@ -91,7 +91,6 @@ const COMPLETE_STATUS = { result: createMockSocietyWideOutput(), }; -/** The report's reform policy, as v1 stores it: a larger standard deduction. */ function answerParameters(url: URL) { return jsonResponse({ status: 'ok', diff --git a/app/src/tests/unit/pages/flagship/ReportV2Policies.test.tsx b/app/src/tests/unit/pages/flagship/ReportV2Policies.test.tsx new file mode 100644 index 000000000..29215c3b8 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/ReportV2Policies.test.tsx @@ -0,0 +1,132 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, userEvent, waitFor } from '@test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import FlagshipReportPage from '@/pages/flagship/Report.page'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { catalogParametersResult } from '@/tests/fixtures/api/publicApi/catalogParametersMocks'; +import { + jsonResponse, + requestsTo, + stubPublicApiFetch, +} from '@/tests/fixtures/api/publicApi/publicApiFetchStub'; +import { + CURRENT_LAW_POLICY_METADATA, + REFORM_POLICY_METADATA, +} from '@/tests/fixtures/api/publicApi/v2ReportPolicyMocks'; +import { + BASELINE_SIMULATION_ID, + EXISTING_REPORT_ID, + EXISTING_REPORT_METADATA, + nationwideSimulationMetadata, + REFORM_SIMULATION_ID, +} from '@/tests/fixtures/hooks/useFlagshipReportMocks'; +import { seedV2ModelCatalog } from '@/tests/fixtures/hooks/v3MetadataWrapper'; +import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; + +/** + * The Report page with the real report hooks: a report opened fresh reads + * its policies as stored, then loads the parameters they name. + */ + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +const POLICIES = new Map( + [REFORM_POLICY_METADATA, CURRENT_LAW_POLICY_METADATA].map((policy) => [policy.id, policy]) +); + +vi.mock('@/api/report', async (importOriginal) => ({ + ...(await importOriginal()), + fetchReportById: async () => EXISTING_REPORT_METADATA, +})); +vi.mock('@/api/simulation', async (importOriginal) => ({ + ...(await importOriginal()), + fetchSimulationById: async (_country: string, simulationId: string) => + simulationId === BASELINE_SIMULATION_ID + ? nationwideSimulationMetadata(BASELINE_SIMULATION_ID, CURRENT_LAW_POLICY_METADATA.id) + : nationwideSimulationMetadata(REFORM_SIMULATION_ID, REFORM_POLICY_METADATA.id), +})); +vi.mock('@/api/policy', async (importOriginal) => ({ + ...(await importOriginal()), + fetchPolicyById: async (_country: string, policyId: string) => POLICIES.get(policyId), +})); + +vi.mock('@/hooks/useCalculationStatus', () => ({ + useCalculationStatus: () => ({ + status: 'complete', + isComplete: true, + isError: false, + result: createMockSocietyWideOutput(), + }), +})); +vi.mock('@/hooks/useStartCalculationOnLoad', () => ({ + useStartCalculationOnLoad: () => undefined, +})); +vi.mock('@/hooks/useReportValidationSnapshot', () => ({ + useReportValidationSnapshot: () => undefined, +})); +vi.mock('@/components/flagship/CalibrationMatches', async (importOriginal) => ({ + ...(await importOriginal()), + useCalibrationMatches: () => null, +})); +vi.mock('@/components/flagship/ValidationPanel', async (importOriginal) => ({ + ...(await importOriginal()), + useModelTrackRecord: () => null, +})); + +const PARAMETERS_PATH = '/v2/model-catalog/parameters'; + +function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + ); +} + +describe('FlagshipReportPage on the v2 routes, with the real report hooks', () => { + beforeEach(() => { + store.dispatch(clearMetadata()); + }); + + afterEach(() => { + store.dispatch(clearMetadata()); + vi.unstubAllGlobals(); + }); + + test("given the reform policy's parameters fail to load then the page says so and try again loads them", async () => { + // Given + const user = userEvent.setup(); + let attempts = 0; + const fetchStub = stubPublicApiFetch({ + [PARAMETERS_PATH]: (url) => { + attempts += 1; + return attempts === 1 + ? jsonResponse({ status: 'error', message: 'Unavailable' }, 503) + : jsonResponse({ + status: 'ok', + message: null, + result: catalogParametersResult(url.searchParams.getAll('name')), + }); + }, + }); + await seedV2ModelCatalog(); + renderPage(); + expect(await screen.findByRole('alert')).toHaveTextContent('Could not load the reform policy'); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + await waitFor(() => + expect(screen.queryByText('Could not load the reform policy')).not.toBeInTheDocument() + ); + expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(2); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); From b5afba1d4615b359b6b0b51f153228083d3fb83b Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:26:35 +0400 Subject: [PATCH 18/28] Show the v2 policy tree's current-law values loading, and say when they fail The open folder's parameters load their current law once they are listed, but the tree dropped that load's status, so the rows showed no value while it loaded and after it failed. Now each row holds a placeholder for its value while it loads, and a failed load shows "Could not load current law for these parameters" with a retry. Co-Authored-By: Claude Opus 5.5 --- .../flagship/policyBrowser/ParameterRow.tsx | 13 ++- .../flagship/policyBrowser/PolicyBrowser.tsx | 9 +++ .../policyBrowser/V3PolicyBrowser.tsx | 17 +++- .../policyBrowser/V3PolicyBrowserV2.test.tsx | 80 +++++++++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) diff --git a/app/src/components/flagship/policyBrowser/ParameterRow.tsx b/app/src/components/flagship/policyBrowser/ParameterRow.tsx index afe361bde..2425b3a99 100644 --- a/app/src/components/flagship/policyBrowser/ParameterRow.tsx +++ b/app/src/components/flagship/policyBrowser/ParameterRow.tsx @@ -1,10 +1,13 @@ import { IconCheck, IconPlus } from '@tabler/icons-react'; +import { SkeletonLine } from '@/components/flagship/states'; import { colors, spacing, typography } from '@/designTokens'; import { ParameterTreeNode } from '@/types/metadata'; interface ParameterRowProps { node: ParameterTreeNode; value: string | null; + /** The value is still loading: a placeholder stands for it. */ + valueLoading?: boolean; inDraft: boolean; divided: boolean; onAdd: () => void; @@ -16,7 +19,14 @@ interface ParameterRowProps { * Either way a click opens it for editing. The description waits in the * tooltip, so the list reads as names. */ -export default function ParameterRow({ node, value, inDraft, divided, onAdd }: ParameterRowProps) { +export default function ParameterRow({ + node, + value, + valueLoading = false, + inDraft, + divided, + onAdd, +}: ParameterRowProps) { return ( @@ -525,7 +536,8 @@ export function DraftRunBar({ draft }: { draft: DraftReform }) { starting || saveMutation.isPending || baseline.pending || - baseline.matchesDraft + baseline.matchesDraft || + currentLawUnavailable } title={ !empty && adjusted.length === 0 diff --git a/app/src/pages/flagship/Build.page.tsx b/app/src/pages/flagship/Build.page.tsx index d197fd330..782d2a6b9 100644 --- a/app/src/pages/flagship/Build.page.tsx +++ b/app/src/pages/flagship/Build.page.tsx @@ -17,6 +17,7 @@ import { useDraftReform, } from '@/libs/draftReform'; import { focusProvision } from '@/libs/flagship/draftEditorFocus'; +import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; import { draftParameterNames, groupMemberPaths } from '@/libs/flagship/v3ParameterNames'; import { ParameterSearchEntry } from '@/libs/parameterSearch'; import { RootState } from '@/store'; @@ -111,7 +112,14 @@ export default function BuildPage() { // the last one ends: a reform takes as many as it needs. } /> )} - + ) : ( <> diff --git a/app/src/tests/unit/pages/flagship/BuildV2.test.tsx b/app/src/tests/unit/pages/flagship/BuildV2.test.tsx index c3bf91869..637fc447b 100644 --- a/app/src/tests/unit/pages/flagship/BuildV2.test.tsx +++ b/app/src/tests/unit/pages/flagship/BuildV2.test.tsx @@ -102,6 +102,48 @@ describe('BuildPage with v3 on the v2 routes', () => { expect(screen.getByRole('combobox', { name: 'Search parameters' })).toBeInTheDocument(); }); + test("given the draft's parameters still loading then its Save buttons wait for current law", async () => { + // Given: a changed value, which saving compares with current law. + let releaseParameters: () => void = () => {}; + const held = new Promise((resolve) => { + releaseParameters = resolve; + }); + stubPublicApiFetch({ + [SEARCH_FILE_PATH]: () => jsonResponse(US_SEARCH_FILE), + [PARAMETERS_PATH]: async (url) => { + await held; + return jsonResponse({ + status: 'ok', + message: null, + result: catalogParametersResult(url.searchParams.getAll('name')), + }); + }, + }); + await seedV2ModelCatalog(); + startDraftReform('us', 'manual'); + addDraftProvision('us', { ...SINGLE_DEDUCTION, value: 20000 }); + const saveButtons = () => [ + screen.getByRole('button', { name: 'Save' }), + screen.getByRole('button', { name: /Save and run/ }), + ]; + + // When + renderPage(); + + // Then + expect(screen.getAllByRole('status', { name: 'Loading parameters' })).toHaveLength(1); + for (const button of saveButtons()) { + expect(button).toBeDisabled(); + } + releaseParameters(); + await waitFor(() => + expect(screen.queryByRole('status', { name: 'Loading parameters' })).not.toBeInTheDocument() + ); + for (const button of saveButtons()) { + expect(button).toBeEnabled(); + } + }); + test('given the search file has not arrived then the page keeps its loading state, with no time limit', async () => { stubCatalog(() => new Promise(() => {})); await seedV2ModelCatalog(); From 581b79ec7ce82c1dcce825361d7aa06b3d017ae0 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:40:37 +0400 Subject: [PATCH 22/28] Hold Build's run when its comparison's current law fails to load, and say so On the v2 routes a saved reform chosen as the comparison loads current law for its parameters, and "Save and run" waits while it loads. When that load failed, the run was offered again with no message, and what the comparison changes was read without current law. The run bar now shows "Could not load current law for the comparison" with a retry, and "Save and run" stays disabled until it loads. On the v1 routes nothing changes. Co-Authored-By: Claude Opus 5.5 --- .../flagship/reform/BaselinePicker.tsx | 8 +++ .../flagship/reform/DraftHeader.tsx | 9 +++ .../unit/pages/flagship/BuildV2.test.tsx | 69 +++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/app/src/components/flagship/reform/BaselinePicker.tsx b/app/src/components/flagship/reform/BaselinePicker.tsx index fe670dd21..78d30c92f 100644 --- a/app/src/components/flagship/reform/BaselinePicker.tsx +++ b/app/src/components/flagship/reform/BaselinePicker.tsx @@ -20,6 +20,7 @@ import { useEnsureV3Parameters } from '@/hooks/useV3Parameters'; import { provisionAdjusts, provisionIntervals, type DraftReform } from '@/libs/draftReform'; import { sameReformParameters } from '@/libs/flagship/reformMatch'; import type { RunReportBaseline } from '@/libs/flagship/runReport'; +import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; import { reformParameterNames } from '@/libs/flagship/v3ParameterNames'; import { RootState } from '@/store'; import type { Reform } from '@/types/ingredients/Reform'; @@ -82,6 +83,13 @@ export function useDraftBaseline(draft: DraftReform) { * what it changes until current law for its parameters is in. */ pending: (!!draft.baselineReformId && isPending) || (!!reform && baselineParameters.waiting), + /** + * Current law for the comparison's parameters failed to load (on the v2 + * routes; on v1 it comes with the whole metadata): what it changes is + * unknown, so the run waits for a retry. + */ + failed: !!reform && V3_API_ROUTES === 'v2' && baselineParameters.status === 'error', + retry: baselineParameters.retry, /** The draft changes just what its comparison changes: the report would show no change. */ matchesDraft: !!reform && draftChanges.length > 0 && sameReformParameters(baselineChanges, draftChanges), diff --git a/app/src/components/flagship/reform/DraftHeader.tsx b/app/src/components/flagship/reform/DraftHeader.tsx index 146b26244..0b77cf974 100644 --- a/app/src/components/flagship/reform/DraftHeader.tsx +++ b/app/src/components/flagship/reform/DraftHeader.tsx @@ -5,6 +5,7 @@ import { useSelector } from 'react-redux'; import { ReformNotFoundError } from '@/api/reformStore'; import { BrowserOnlyIdError } from '@/api/v2Associations'; import { isV2ApiError } from '@/api/v2UserClient'; +import { ErrorState } from '@/components/flagship/states'; import { Button, Popover, @@ -536,6 +537,7 @@ export function DraftRunBar({ starting || saveMutation.isPending || baseline.pending || + baseline.failed || baseline.matchesDraft || currentLawUnavailable } @@ -581,6 +583,13 @@ export function DraftRunBar({

    )} + {baseline.failed && ( + + )} {(runReport.error || saveError) && (

    ({ @@ -30,6 +33,13 @@ vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ V3_API_ROUTES: 'v2', })); +/** The saved reforms Build offers as the comparison; none unless a test names them. */ +const mockFindMine = vi.fn(async (): Promise => []); +vi.mock('@/api/reformStore', async (importOriginal) => ({ + ...(await importOriginal()), + getReformStore: () => ({ findMine: mockFindMine }), +})); + const SEARCH_FILE_PATH = '/v2/parameters/search-file'; const PARAMETERS_PATH = '/v2/model-catalog/parameters'; @@ -178,4 +188,63 @@ describe('BuildPage with v3 on the v2 routes', () => { provision.path === STANDARD_DEDUCTION_SINGLE ? 15000 : 30000 ); }); + + test("given the comparison's parameters fail to load then the page says so and its run waits for them", async () => { + // Given: a draft compared with a saved reform of the child tax credit. + const user = userEvent.setup(); + const comparison: Reform = { + id: 'rf-ctc', + countryId: 'us', + label: 'A larger child tax credit', + parameters: [ + { + name: CTC_FIRST_BRACKET_AMOUNT, + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 3000 }], + }, + ], + baseline: 'current-law', + provenance: { source: 'manual' }, + }; + mockFindMine.mockResolvedValue([comparison]); + let comparisonAttempts = 0; + stubPublicApiFetch({ + [SEARCH_FILE_PATH]: () => jsonResponse(US_SEARCH_FILE), + [PARAMETERS_PATH]: (url) => { + const names = url.searchParams.getAll('name'); + if (names.includes(CTC_FIRST_BRACKET_AMOUNT)) { + comparisonAttempts += 1; + if (comparisonAttempts === 1) { + return jsonResponse({ status: 'error', message: 'Unavailable' }, 503); + } + } + return jsonResponse({ + status: 'ok', + message: null, + result: catalogParametersResult(names), + }); + }, + }); + await seedV2ModelCatalog(); + startDraftReform('us', 'manual'); + addDraftProvision('us', { ...SINGLE_DEDUCTION, value: 20000 }); + setDraftBaseline(comparison.id); + renderPage(); + const run = () => screen.getByRole('button', { name: /Save and run/ }); + + // Then + expect( + await screen.findByText('Could not load current law for the comparison') + ).toBeInTheDocument(); + expect(run()).toBeDisabled(); + + // When + await user.click(screen.getByRole('button', { name: 'Try again' })); + + // Then + await waitFor(() => expect(run()).toBeEnabled()); + expect( + screen.queryByText('Could not load current law for the comparison') + ).not.toBeInTheDocument(); + expect(comparisonAttempts).toBe(2); + }); }); From 3c7c9412057deba537712ff682833cf05da150ff Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:00:17 +0400 Subject: [PATCH 23/28] Regenerate the Public API client from the final v2 metadata routes The OpenAPI document is now the Public API's final v2 metadata routes, exported from the stack ending at the parameter search file. What changed in the generated types: - The model catalog's parameters and parameter summaries always carry `economy` and `household` (each may still be null). - A catalog variable always carries `is_input_variable`, `hidden_input`, `module_name`, `adds_parameter_path`, `subtracts_parameter_path` and `enum_values` (each may be null), and gains `unit` and `definition_period`. - A search file parameter's `editable_changes` may be left out but is never null. - The metadata routes declare 422 for malformed or unknown query parameters in place of 400. `/v2/parameters/children` declares ETag, Cache-Control and Vary on 200 and a 304; the search file declares those headers on its 304. Several descriptions are reworded. The app reads these fields as before (a null `economy` or `household` still reads as true, and missing editability changes as none), so no code changes. Co-Authored-By: Claude Opus 5.5 --- app/src/api/publicApi/openapi.json | 1219 +++++++++++++--------------- app/src/api/publicApi/schema.d.ts | 751 +++++++---------- 2 files changed, 847 insertions(+), 1123 deletions(-) diff --git a/app/src/api/publicApi/openapi.json b/app/src/api/publicApi/openapi.json index 9008b2d4a..505a52475 100644 --- a/app/src/api/publicApi/openapi.json +++ b/app/src/api/publicApi/openapi.json @@ -4689,7 +4689,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -4701,7 +4701,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -4712,7 +4712,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -4724,23 +4724,13 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } } }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -4839,7 +4829,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -4851,7 +4841,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -4862,7 +4852,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -4874,7 +4864,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -5005,7 +4995,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -5017,7 +5007,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -5028,7 +5018,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -5040,23 +5030,13 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } } }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -5188,7 +5168,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -5200,7 +5180,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -5211,7 +5191,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -5223,23 +5203,13 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } } }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -5354,7 +5324,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -5366,7 +5336,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -5377,7 +5347,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -5389,23 +5359,13 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } } }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -5555,7 +5515,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -5567,7 +5527,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -5578,7 +5538,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -5590,23 +5550,13 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } } }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -5721,7 +5671,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -5733,7 +5683,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -5744,7 +5694,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -5756,23 +5706,13 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } } }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -5947,7 +5887,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -5959,7 +5899,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -5970,7 +5910,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -5982,23 +5922,13 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } } }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -6055,6 +5985,7 @@ "/v2/parameters/children": { "get": { "summary": "List one level of a catalog version's parameter tree", + "description": "Lists the direct children of `parent_path` (the top level when empty). With `reform_changeable=true`, only the children a reform can change, sorted by label, with child counts that count only those. Invalid query parameters return 422, including a malformed policyengine_version, an unsupported country, and an unknown or repeated parameter. A catalog version without a tree (publication writes trees only for the versions it publishes and for the installed version with catalog format 2) returns 503 with a message naming it.", "operationId": "list_parameter_children_v2_parameters_children_get", "parameters": [ { @@ -6152,10 +6083,53 @@ "$ref": "#/components/schemas/MetadataParameterChildPageResponse" } } + }, + "headers": { + "ETag": { + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", + "schema": { + "type": "string" + } + } } }, - "400": { - "description": "The resource request or PolicyEngine.py version is invalid.", + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "The requested catalog resource is absent.", "content": { "application/json": { "schema": { @@ -6164,6 +6138,148 @@ } } }, + "405": { + "description": "The dormant v2 metadata resources support GET only.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "422": { + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "500": { + "description": "The resource query failed internally.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + }, + "503": { + "description": "The initialized v2 catalog is unavailable, or the catalog version has no parameter tree (publication writes trees only for the versions it publishes and for the installed version with catalog format 2); the message then names the version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ErrorResponse" + } + } + } + } + } + } + }, + "/v2/parameters/search-file": { + "get": { + "summary": "Get a catalog version's parameter search file", + "operationId": "get_parameter_search_file_v2_parameters_search_file_get", + "parameters": [ + { + "name": "country_id", + "in": "query", + "required": true, + "schema": { + "enum": [ + "us", + "uk" + ], + "type": "string", + "description": "Supported PolicyEngine country ID", + "title": "Country Id" + }, + "description": "Supported PolicyEngine country ID" + }, + { + "name": "policyengine_version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "maxLength": 128, + "description": "Canonical non-placeholder PEP 440 version" + }, + { + "type": "null" + } + ], + "title": "Policyengine Version" + } + } + ], + "responses": { + "200": { + "description": "The catalog version's parameter search file, gzip-encoded when the request accepts gzip.", + "headers": { + "ETag": { + "description": "Strong tag of this representation: the SHA-256 of the uncompressed JSON, with \"-gzip\" appended for the gzip-encoded one.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", + "schema": { + "type": "string" + } + }, + "Content-Encoding": { + "description": "gzip when the request accepts gzip; absent otherwise.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataParameterSearchFileContent" + } + } + } + }, + "304": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", + "schema": { + "type": "string" + } + } + } + }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -6185,7 +6301,7 @@ } }, "422": { - "description": "The request parameters do not match the resource schema.", + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", "content": { "application/json": { "schema": { @@ -6205,7 +6321,7 @@ } }, "503": { - "description": "The initialized v2 catalog is unavailable.", + "description": "The initialized v2 catalog is unavailable, or the catalog version has no parameter search file: only catalog format 2 versions that publication has published, or run with installed, since search files were added have one. The message then names the version.", "content": { "application/json": { "schema": { @@ -6278,7 +6394,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -6290,7 +6406,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -6301,7 +6417,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -6313,23 +6429,13 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } } }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -6516,7 +6622,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -6528,7 +6634,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -6539,7 +6645,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -6551,23 +6657,13 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } } }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -6682,7 +6778,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -6694,7 +6790,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -6705,7 +6801,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -6717,23 +6813,13 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } } }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -6865,7 +6951,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -6877,7 +6963,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -6888,7 +6974,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -6900,23 +6986,13 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } } }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -7031,7 +7107,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -7043,7 +7119,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -7054,7 +7130,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -7066,23 +7142,13 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } } }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -7232,7 +7298,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -7244,7 +7310,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -7255,7 +7321,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -7267,23 +7333,13 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } } }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -7397,7 +7453,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -7409,7 +7465,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -7420,7 +7476,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -7432,23 +7488,13 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } } }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -7563,7 +7609,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -7575,7 +7621,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -7586,7 +7632,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -7598,23 +7644,13 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } } }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -7719,7 +7755,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -7731,7 +7767,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -7742,7 +7778,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -7754,23 +7790,13 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } } }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -7876,164 +7902,7 @@ }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", - "schema": { - "type": "string" - } - }, - "Cache-Control": { - "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", - "schema": { - "type": "string" - } - }, - "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "Not modified: If-None-Match names the current ETag. No body is sent.", - "headers": { - "ETag": { - "description": "Strong validator: a hash of the exact response body.", - "schema": { - "type": "string" - } - }, - "Cache-Control": { - "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", - "schema": { - "type": "string" - } - }, - "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", - "schema": { - "type": "string" - } - } - } - }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, - "404": { - "description": "The requested catalog resource is absent.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, - "405": { - "description": "The dormant v2 metadata resources support GET only.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, - "422": { - "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, - "500": { - "description": "The resource query failed internally.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, - "503": { - "description": "The initialized v2 catalog is unavailable.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - } - } - } - }, - "/v2/model-catalog/variables": { - "get": { - "summary": "Get every variable of one PolicyEngine.py catalog version", - "description": "Returns all of the selected catalog version's variables in name order, in one response. Where a variable's adds or subtracts list comes from a parameter, part_parameter_values carries that parameter's current-law values by date, so a client can pick the list for any year.", - "operationId": "get_catalog_variables_v2_model_catalog_variables_get", - "parameters": [ - { - "name": "country_id", - "in": "query", - "required": true, - "schema": { - "enum": [ - "us", - "uk" - ], - "type": "string", - "description": "Supported PolicyEngine country ID", - "title": "Country Id" - }, - "description": "Supported PolicyEngine country ID" - }, - { - "name": "policyengine_version", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string", - "maxLength": 128, - "description": "Canonical non-placeholder PEP 440 version" - }, - { - "type": "null" - } - ], - "title": "Policyengine Version" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MetadataCatalogVariablesResponse" - } - } - }, - "headers": { - "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -8045,7 +7914,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -8056,7 +7925,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -8068,19 +7937,9 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", - "schema": { - "type": "string" - } - } - } - }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" + "type": "string" } } } @@ -8138,11 +7997,11 @@ } } }, - "/v2/model-catalog/parameters": { + "/v2/model-catalog/variables": { "get": { - "summary": "Get several parameters of one catalog version by name", - "description": "Returns the named parameters (repeat name for each, at most 100) in name order, each with its economy and household flags and its current-law values by date, plus the stored labels of their ancestor nodes for breadcrumbs and the requested names the version has no parameter for. A client showing or editing a reform loads all of its parameters in this one request.", - "operationId": "get_catalog_parameters_v2_model_catalog_parameters_get", + "summary": "Get every variable of one PolicyEngine.py catalog version", + "description": "Returns all of the selected catalog version's variables in name order, in one response. Where a variable's adds or subtracts list comes from a parameter, part_parameter_values carries that parameter's current-law values by date, so a client can pick the list for any year.", + "operationId": "get_catalog_variables_v2_model_catalog_variables_get", "parameters": [ { "name": "country_id", @@ -8176,25 +8035,6 @@ ], "title": "Policyengine Version" } - }, - { - "name": "name", - "in": "query", - "required": true, - "schema": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 512, - "description": "Exact parameter path" - }, - "minItems": 1, - "maxItems": 100, - "description": "Exact parameter paths; repeat the key for each one", - "title": "Name" - }, - "description": "Exact parameter paths; repeat the key for each one" } ], "responses": { @@ -8203,13 +8043,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MetadataCatalogParametersResponse" + "$ref": "#/components/schemas/MetadataCatalogVariablesResponse" } } }, "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -8221,7 +8061,7 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } @@ -8232,7 +8072,7 @@ "description": "Not modified: If-None-Match names the current ETag. No body is sent.", "headers": { "ETag": { - "description": "Strong validator: a hash of the exact response body.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } @@ -8244,23 +8084,13 @@ } }, "Vary": { - "description": "Includes Origin, so caches keep responses apart by origin.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } } }, - "400": { - "description": "The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" - } - } - } - }, "404": { "description": "The requested catalog resource is absent.", "content": { @@ -8314,10 +8144,11 @@ } } }, - "/v2/parameters/search-file": { + "/v2/model-catalog/parameters": { "get": { - "summary": "Get a catalog version's parameter search file", - "operationId": "get_parameter_search_file_v2_parameters_search_file_get", + "summary": "Get several parameters of one catalog version by name", + "description": "Returns the named parameters (repeat name for each, at most 100) in name order, each with its economy and household flags and its current-law values by date, plus the stored labels of their ancestor nodes for breadcrumbs and the requested names the version has no parameter for. A client showing or editing a reform loads all of its parameters in this one request.", + "operationId": "get_catalog_parameters_v2_model_catalog_parameters_get", "parameters": [ { "name": "country_id", @@ -8351,54 +8182,77 @@ ], "title": "Policyengine Version" } + }, + { + "name": "name", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Exact parameter path" + }, + "minItems": 1, + "maxItems": 100, + "description": "Exact parameter paths; repeat the key for each one", + "title": "Name" + }, + "description": "Exact parameter paths; repeat the key for each one" } ], "responses": { "200": { - "description": "The catalog version's parameter search file, gzip-encoded when the request accepts gzip.", + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataCatalogParametersResponse" + } + } + }, "headers": { "ETag": { - "description": "Strong tag of this representation: the SHA-256 of the uncompressed JSON, with \"-gzip\" appended for the gzip-encoded one.", + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { "type": "string" } }, "Cache-Control": { - "description": "public, max-age=86400 for a named policyengine_version; public, max-age=300 for the running version.", - "schema": { - "type": "string" - } - }, - "Content-Encoding": { - "description": "gzip when the request accepts gzip; absent otherwise.", + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", "schema": { "type": "string" } }, "Vary": { - "description": "Accept-Encoding.", + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", "schema": { "type": "string" } } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MetadataParameterSearchFileContent" - } - } } }, "304": { - "description": "If-None-Match names this file's content; the response has no body." - }, - "400": { - "description": "The resource request or PolicyEngine.py version is invalid.", - "content": { - "application/json": { + "description": "Not modified: If-None-Match names the current ETag. No body is sent.", + "headers": { + "ETag": { + "description": "Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag.", "schema": { - "$ref": "#/components/schemas/V2ErrorResponse" + "type": "string" + } + }, + "Cache-Control": { + "description": "public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight.", + "schema": { + "type": "string" + } + }, + "Vary": { + "description": "Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value.", + "schema": { + "type": "string" } } } @@ -8424,7 +8278,7 @@ } }, "422": { - "description": "The request parameters do not match the resource schema.", + "description": "The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version.", "content": { "application/json": { "schema": { @@ -9228,6 +9082,8 @@ "description", "data_type", "unit", + "economy", + "household", "values" ], "title": "MetadataCatalogParameter", @@ -10554,21 +10410,201 @@ "title": "Status", "default": "ok" }, - "message": { - "type": "null", - "title": "Message" + "message": { + "type": "null", + "title": "Message" + }, + "result": { + "$ref": "#/components/schemas/MetadataPageResult_MetadataParameterSummary_" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "result" + ], + "title": "MetadataParameterPageResponse", + "description": "Success response holding one page of parameters." + }, + "MetadataParameterSearchFileContent": { + "properties": { + "format": { + "type": "integer", + "title": "Format" + }, + "country_id": { + "type": "string", + "title": "Country Id" + }, + "policyengine_version": { + "type": "string", + "title": "Policyengine Version" + }, + "parameters": { + "items": { + "$ref": "#/components/schemas/MetadataParameterSearchFileParameter" + }, + "type": "array", + "title": "Parameters" + }, + "other_parameters": { + "items": { + "$ref": "#/components/schemas/MetadataParameterSearchFileLabel" + }, + "type": "array", + "title": "Other Parameters" + }, + "nodes": { + "items": { + "$ref": "#/components/schemas/MetadataParameterSearchFileNode" + }, + "type": "array", + "title": "Nodes" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "format", + "country_id", + "policyengine_version", + "parameters", + "other_parameters", + "nodes" + ], + "title": "MetadataParameterSearchFileContent", + "description": "One catalog version's parameter search file, ordered by path.\n\nIt holds what v3's parameter search reads, without the catalog's values.\nA reader applies v1's label fallback where a label is null." + }, + "MetadataParameterSearchFileEditabilityChange": { + "properties": { + "start_date": { + "type": "string", + "format": "date", + "title": "Start Date" + }, + "editable": { + "type": "boolean", + "title": "Editable" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "start_date", + "editable" + ], + "title": "MetadataParameterSearchFileEditabilityChange", + "description": "From ``start_date`` (an ISO date), whether a draft can edit the value." + }, + "MetadataParameterSearchFileLabel": { + "properties": { + "path": { + "type": "string", + "title": "Path" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Label" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "path", + "label" + ], + "title": "MetadataParameterSearchFileLabel", + "description": "Another parameter under ``gov``: its path and stored label, or null." + }, + "MetadataParameterSearchFileNode": { + "properties": { + "path": { + "type": "string", + "title": "Path" + }, + "label": { + "type": "string", + "title": "Label" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "path", + "label" + ], + "title": "MetadataParameterSearchFileNode", + "description": "``gov`` or a node under it that has a stored label." + }, + "MetadataParameterSearchFileParameter": { + "properties": { + "path": { + "type": "string", + "title": "Path" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Label" + }, + "unit": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Unit" }, - "result": { - "$ref": "#/components/schemas/MetadataPageResult_MetadataParameterSummary_" + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "editable": { + "type": "boolean", + "title": "Editable" + }, + "editable_changes": { + "items": { + "$ref": "#/components/schemas/MetadataParameterSearchFileEditabilityChange" + }, + "type": "array", + "title": "Editable Changes" } }, "additionalProperties": false, "type": "object", "required": [ - "result" + "path", + "label", + "unit", + "description", + "editable" ], - "title": "MetadataParameterPageResponse", - "description": "Success response holding one page of parameters." + "title": "MetadataParameterSearchFileParameter", + "description": "A parameter a reform can change, with what v3's search indexes.\n\n``label`` is the stored label, or null when none is stored. ``editable``\nsays whether a draft can edit the earliest current-law value (a number, a\nyes/no value or an infinite threshold); ``editable_changes``, present only\nwhen that changes over time and never null, lists the dates from which it\nchanges." }, "MetadataParameterSummary": { "properties": { @@ -10656,7 +10692,9 @@ "label", "description", "data_type", - "unit" + "unit", + "economy", + "household" ], "title": "MetadataParameterSummary", "description": "A policy parameter, described without its values.\n\nIts current-law values are listed by GET /v2/parameter-values. economy and\nhousehold say whether a reform of an economy or of a household may change\nit; they are null in versions published before catalog format 2." @@ -10820,7 +10858,7 @@ "default_dataset_id" ], "title": "MetadataRegion", - "description": "A region the model can simulate, such as a country, state or constituency.\n\nWhen requires_filter is true, filter_strategy says how the region selects\nhouseholds of its default dataset. With \"row_filter\", it keeps the\nhouseholds whose filter_field column equals filter_value (or is in it,\nwhen filter_value is a JSON list). With \"weight_replacement\", it keeps\nevery household and gives each the weight in row filter_value of the\nweight matrix that filter_field names in the certified data release." + "description": "A region the model can simulate, such as a country, state or constituency.\n\nWhen requires_filter is true, filter_strategy says how the region selects\nhouseholds of its default dataset. With \"row_filter\", it keeps the\nhouseholds whose filter_field column equals filter_value (or is in it,\nwhen filter_value is a JSON list)." }, "MetadataRegionDetailResponse": { "properties": { @@ -11090,6 +11128,28 @@ } ], "title": "Enum Values" + }, + "unit": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Unit" + }, + "definition_period": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Definition Period" } }, "additionalProperties": false, @@ -11104,10 +11164,18 @@ "possible_values", "default_value", "adds", - "subtracts" + "subtracts", + "is_input_variable", + "hidden_input", + "module_name", + "adds_parameter_path", + "subtracts_parameter_path", + "enum_values", + "unit", + "definition_period" ], "title": "MetadataVariable", - "description": "A variable the tax-benefit model computes or accepts as input.\n\nadds and subtracts, when present, list the variables summed into or\nsubtracted from this one. When the model takes that list from a\nparameter, adds_parameter_path or subtracts_parameter_path names the\nparameter, whose dated values give the list for each year (adds and\nsubtracts hold its value on 2025-01-01). The catalog format 2 fields\n(is_input_variable, hidden_input, module_name, the two parameter paths\nand enum_values) are null in versions published before that format." + "description": "A variable the tax-benefit model computes or accepts as input.\n\nadds and subtracts, when present, list the variables summed into or\nsubtracted from this one. When the model takes that list from a\nparameter, adds_parameter_path or subtracts_parameter_path names the\nparameter, whose dated values give the list for each year (adds and\nsubtracts hold its value on 2025-01-01). unit is the country package's\nunit (for example ``currency-USD``) and definition_period the period a\nvalue covers (``year``, ``month`` or ``eternity``). The catalog format 2\nfields (is_input_variable, hidden_input, module_name, the two parameter\npaths, enum_values, unit and definition_period) are null in versions\npublished before that format." }, "MetadataVariableDetailResponse": { "properties": { @@ -14141,193 +14209,6 @@ ], "title": "V2ErrorResponse", "description": "Strict error response shared by every API v2 resource." - }, - "MetadataParameterSearchFileContent": { - "properties": { - "format": { - "type": "integer", - "title": "Format" - }, - "country_id": { - "type": "string", - "title": "Country Id" - }, - "policyengine_version": { - "type": "string", - "title": "Policyengine Version" - }, - "parameters": { - "items": { - "$ref": "#/components/schemas/MetadataParameterSearchFileParameter" - }, - "type": "array", - "title": "Parameters" - }, - "other_parameters": { - "items": { - "$ref": "#/components/schemas/MetadataParameterSearchFileLabel" - }, - "type": "array", - "title": "Other Parameters" - }, - "nodes": { - "items": { - "$ref": "#/components/schemas/MetadataParameterSearchFileNode" - }, - "type": "array", - "title": "Nodes" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "format", - "country_id", - "policyengine_version", - "parameters", - "other_parameters", - "nodes" - ], - "title": "MetadataParameterSearchFileContent", - "description": "One catalog version's parameter search file, ordered by path.\n\nIt holds what v3's parameter search reads, without the catalog's values.\nA reader applies v1's label fallback where a label is null." - }, - "MetadataParameterSearchFileEditabilityChange": { - "properties": { - "start_date": { - "type": "string", - "format": "date", - "title": "Start Date" - }, - "editable": { - "type": "boolean", - "title": "Editable" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "start_date", - "editable" - ], - "title": "MetadataParameterSearchFileEditabilityChange", - "description": "From ``start_date`` (an ISO date), whether a draft can edit the value." - }, - "MetadataParameterSearchFileLabel": { - "properties": { - "path": { - "type": "string", - "title": "Path" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Label" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "path", - "label" - ], - "title": "MetadataParameterSearchFileLabel", - "description": "Another parameter under ``gov``: its path and stored label, or null." - }, - "MetadataParameterSearchFileNode": { - "properties": { - "path": { - "type": "string", - "title": "Path" - }, - "label": { - "type": "string", - "title": "Label" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "path", - "label" - ], - "title": "MetadataParameterSearchFileNode", - "description": "A node under ``gov`` that has a stored label." - }, - "MetadataParameterSearchFileParameter": { - "properties": { - "path": { - "type": "string", - "title": "Path" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Label" - }, - "unit": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Unit" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description" - }, - "editable": { - "type": "boolean", - "title": "Editable" - }, - "editable_changes": { - "anyOf": [ - { - "items": { - "$ref": "#/components/schemas/MetadataParameterSearchFileEditabilityChange" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "title": "Editable Changes" - } - }, - "additionalProperties": false, - "type": "object", - "required": [ - "path", - "label", - "unit", - "description", - "editable" - ], - "title": "MetadataParameterSearchFileParameter", - "description": "A parameter a reform can change, with what v3's search indexes.\n\n``label`` is the stored label, or null when none is stored. ``editable``\nsays whether a draft can edit the earliest current-law value (a number, a\nyes/no value or an infinite threshold); ``editable_changes``, when\npresent, lists the dates from which that changes." } }, "securitySchemes": { diff --git a/app/src/api/publicApi/schema.d.ts b/app/src/api/publicApi/schema.d.ts index 5df065bb9..2cda7dc34 100644 --- a/app/src/api/publicApi/schema.d.ts +++ b/app/src/api/publicApi/schema.d.ts @@ -471,7 +471,10 @@ export interface paths { path?: never; cookie?: never; }; - /** List one level of a catalog version's parameter tree */ + /** + * List one level of a catalog version's parameter tree + * @description Lists the direct children of `parent_path` (the top level when empty). With `reform_changeable=true`, only the children a reform can change, sorted by label, with child counts that count only those. Invalid query parameters return 422, including a malformed policyengine_version, an unsupported country, and an unknown or repeated parameter. A catalog version without a tree (publication writes trees only for the versions it publishes and for the installed version with catalog format 2) returns 503 with a message naming it. + */ get: operations['list_parameter_children_v2_parameters_children_get']; put?: never; post?: never; @@ -481,6 +484,23 @@ export interface paths { patch?: never; trace?: never; }; + '/v2/parameters/search-file': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a catalog version's parameter search file */ + get: operations['get_parameter_search_file_v2_parameters_search_file_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/v2/parameters/{parameter_id}': { parameters: { query?: never; @@ -694,23 +714,6 @@ export interface paths { patch?: never; trace?: never; }; - '/v2/parameters/search-file': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get a catalog version's parameter search file */ - get: operations['get_parameter_search_file_v2_parameters_search_file_get']; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; } export type webhooks = Record; export interface components { @@ -1063,9 +1066,9 @@ export interface components { /** Unit */ unit: string | null; /** Economy */ - economy?: boolean | null; + economy: boolean | null; /** Household */ - household?: boolean | null; + household: boolean | null; /** Values */ values: components['schemas']['MetadataDatedValue'][]; }; @@ -1692,6 +1695,84 @@ export interface components { message?: null; result: components['schemas']['MetadataPageResult_MetadataParameterSummary_']; }; + /** + * MetadataParameterSearchFileContent + * @description One catalog version's parameter search file, ordered by path. + * + * It holds what v3's parameter search reads, without the catalog's values. + * A reader applies v1's label fallback where a label is null. + */ + MetadataParameterSearchFileContent: { + /** Format */ + format: number; + /** Country Id */ + country_id: string; + /** Policyengine Version */ + policyengine_version: string; + /** Parameters */ + parameters: components['schemas']['MetadataParameterSearchFileParameter'][]; + /** Other Parameters */ + other_parameters: components['schemas']['MetadataParameterSearchFileLabel'][]; + /** Nodes */ + nodes: components['schemas']['MetadataParameterSearchFileNode'][]; + }; + /** + * MetadataParameterSearchFileEditabilityChange + * @description From ``start_date`` (an ISO date), whether a draft can edit the value. + */ + MetadataParameterSearchFileEditabilityChange: { + /** + * Start Date + * Format: date + */ + start_date: string; + /** Editable */ + editable: boolean; + }; + /** + * MetadataParameterSearchFileLabel + * @description Another parameter under ``gov``: its path and stored label, or null. + */ + MetadataParameterSearchFileLabel: { + /** Path */ + path: string; + /** Label */ + label: string | null; + }; + /** + * MetadataParameterSearchFileNode + * @description ``gov`` or a node under it that has a stored label. + */ + MetadataParameterSearchFileNode: { + /** Path */ + path: string; + /** Label */ + label: string; + }; + /** + * MetadataParameterSearchFileParameter + * @description A parameter a reform can change, with what v3's search indexes. + * + * ``label`` is the stored label, or null when none is stored. ``editable`` + * says whether a draft can edit the earliest current-law value (a number, a + * yes/no value or an infinite threshold); ``editable_changes``, present only + * when that changes over time and never null, lists the dates from which it + * changes. + */ + MetadataParameterSearchFileParameter: { + /** Path */ + path: string; + /** Label */ + label: string | null; + /** Unit */ + unit: string | null; + /** Description */ + description: string | null; + /** Editable */ + editable: boolean; + /** Editable Changes */ + editable_changes?: components['schemas']['MetadataParameterSearchFileEditabilityChange'][]; + }; /** * MetadataParameterSummary * @description A policy parameter, described without its values. @@ -1717,9 +1798,9 @@ export interface components { /** Unit */ unit: string | null; /** Economy */ - economy?: boolean | null; + economy: boolean | null; /** Household */ - household?: boolean | null; + household: boolean | null; }; /** * MetadataParameterValueDetailResponse @@ -1758,9 +1839,7 @@ export interface components { * When requires_filter is true, filter_strategy says how the region selects * households of its default dataset. With "row_filter", it keeps the * households whose filter_field column equals filter_value (or is in it, - * when filter_value is a JSON list). With "weight_replacement", it keeps - * every household and gives each the weight in row filter_value of the - * weight matrix that filter_field names in the certified data release. + * when filter_value is a JSON list). */ MetadataRegion: { /** @@ -1866,9 +1945,12 @@ export interface components { * subtracted from this one. When the model takes that list from a * parameter, adds_parameter_path or subtracts_parameter_path names the * parameter, whose dated values give the list for each year (adds and - * subtracts hold its value on 2025-01-01). The catalog format 2 fields - * (is_input_variable, hidden_input, module_name, the two parameter paths - * and enum_values) are null in versions published before that format. + * subtracts hold its value on 2025-01-01). unit is the country package's + * unit (for example ``currency-USD``) and definition_period the period a + * value covers (``year``, ``month`` or ``eternity``). The catalog format 2 + * fields (is_input_variable, hidden_input, module_name, the two parameter + * paths, enum_values, unit and definition_period) are null in versions + * published before that format. */ MetadataVariable: { /** @@ -1894,17 +1976,21 @@ export interface components { /** Subtracts */ subtracts: string[] | null; /** Is Input Variable */ - is_input_variable?: boolean | null; + is_input_variable: boolean | null; /** Hidden Input */ - hidden_input?: boolean | null; + hidden_input: boolean | null; /** Module Name */ - module_name?: string | null; + module_name: string | null; /** Adds Parameter Path */ - adds_parameter_path?: string | null; + adds_parameter_path: string | null; /** Subtracts Parameter Path */ - subtracts_parameter_path?: string | null; + subtracts_parameter_path: string | null; /** Enum Values */ - enum_values?: components['schemas']['MetadataEnumValue'][] | null; + enum_values: components['schemas']['MetadataEnumValue'][] | null; + /** Unit */ + unit: string | null; + /** Definition Period */ + definition_period: string | null; }; /** * MetadataVariableDetailResponse @@ -3084,85 +3170,6 @@ export interface components { /** Message */ message: string; }; - /** - * MetadataParameterSearchFileContent - * @description One catalog version's parameter search file, ordered by path. - * - * It holds what v3's parameter search reads, without the catalog's values. - * A reader applies v1's label fallback where a label is null. - */ - MetadataParameterSearchFileContent: { - /** Format */ - format: number; - /** Country Id */ - country_id: string; - /** Policyengine Version */ - policyengine_version: string; - /** Parameters */ - parameters: components['schemas']['MetadataParameterSearchFileParameter'][]; - /** Other Parameters */ - other_parameters: components['schemas']['MetadataParameterSearchFileLabel'][]; - /** Nodes */ - nodes: components['schemas']['MetadataParameterSearchFileNode'][]; - }; - /** - * MetadataParameterSearchFileEditabilityChange - * @description From ``start_date`` (an ISO date), whether a draft can edit the value. - */ - MetadataParameterSearchFileEditabilityChange: { - /** - * Start Date - * Format: date - */ - start_date: string; - /** Editable */ - editable: boolean; - }; - /** - * MetadataParameterSearchFileLabel - * @description Another parameter under ``gov``: its path and stored label, or null. - */ - MetadataParameterSearchFileLabel: { - /** Path */ - path: string; - /** Label */ - label: string | null; - }; - /** - * MetadataParameterSearchFileNode - * @description A node under ``gov`` that has a stored label. - */ - MetadataParameterSearchFileNode: { - /** Path */ - path: string; - /** Label */ - label: string; - }; - /** - * MetadataParameterSearchFileParameter - * @description A parameter a reform can change, with what v3's search indexes. - * - * ``label`` is the stored label, or null when none is stored. ``editable`` - * says whether a draft can edit the earliest current-law value (a number, a - * yes/no value or an infinite threshold); ``editable_changes``, when - * present, lists the dates from which that changes. - */ - MetadataParameterSearchFileParameter: { - /** Path */ - path: string; - /** Label */ - label: string | null; - /** Unit */ - unit: string | null; - /** Description */ - description: string | null; - /** Editable */ - editable: boolean; - /** Editable Changes */ - editable_changes?: - | components['schemas']['MetadataParameterSearchFileEditabilityChange'][] - | null; - }; }; responses: never; parameters: never; @@ -6494,11 +6501,11 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; @@ -6509,25 +6516,16 @@ export interface operations { /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -6591,11 +6589,11 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; @@ -6606,11 +6604,11 @@ export interface operations { /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; @@ -6690,11 +6688,11 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; @@ -6705,25 +6703,16 @@ export interface operations { /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -6791,11 +6780,11 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; @@ -6806,25 +6795,16 @@ export interface operations { /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -6890,11 +6870,11 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; @@ -6905,25 +6885,16 @@ export interface operations { /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -6993,11 +6964,11 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; @@ -7008,25 +6979,16 @@ export interface operations { /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -7092,11 +7054,11 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; @@ -7107,25 +7069,16 @@ export interface operations { /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -7197,11 +7150,11 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; @@ -7212,25 +7165,16 @@ export interface operations { /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -7302,20 +7246,30 @@ export interface operations { /** @description Successful Response */ 200: { headers: { + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ + Vary?: string; [name: string]: unknown; }; content: { 'application/json': components['schemas']['MetadataParameterChildPageResponse']; }; }; - /** @description The resource request or PolicyEngine.py version is invalid. */ - 400: { + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ + 304: { headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ + Vary?: string; + [name: string]: unknown; }; + content?: never; }; /** @description The requested catalog resource is absent. */ 404: { @@ -7335,7 +7289,7 @@ export interface operations { 'application/json': components['schemas']['V2ErrorResponse']; }; }; - /** @description The request parameters do not match the resource schema. */ + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ 422: { headers: { [name: string]: unknown; @@ -7353,7 +7307,7 @@ export interface operations { 'application/json': components['schemas']['V2ErrorResponse']; }; }; - /** @description The initialized v2 catalog is unavailable. */ + /** @description The initialized v2 catalog is unavailable, or the catalog version has no parameter tree (publication writes trees only for the versions it publishes and for the installed version with catalog format 2); the message then names the version. */ 503: { headers: { [name: string]: unknown; @@ -7364,7 +7318,7 @@ export interface operations { }; }; }; - get_parameter_v2_parameters__parameter_id__get: { + get_parameter_search_file_v2_parameters_search_file_get: { parameters: { query: { /** @description Supported PolicyEngine country ID */ @@ -7372,50 +7326,41 @@ export interface operations { policyengine_version?: string | null; }; header?: never; - path: { - parameter_id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Successful Response */ + /** @description The catalog version's parameter search file, gzip-encoded when the request accepts gzip. */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong tag of this representation: the SHA-256 of the uncompressed JSON, with "-gzip" appended for the gzip-encoded one. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; + /** @description gzip when the request accepts gzip; absent otherwise. */ + 'Content-Encoding'?: string; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['MetadataParameterDetailResponse']; + 'application/json': components['schemas']['MetadataParameterSearchFileContent']; }; }; /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -7452,7 +7397,7 @@ export interface operations { 'application/json': components['schemas']['V2ErrorResponse']; }; }; - /** @description The initialized v2 catalog is unavailable. */ + /** @description The initialized v2 catalog is unavailable, or the catalog version has no parameter search file: only catalog format 2 versions that publication has published, or run with installed, since search files were added have one. The message then names the version. */ 503: { headers: { [name: string]: unknown; @@ -7463,24 +7408,17 @@ export interface operations { }; }; }; - list_parameter_values_v2_parameter_values_get: { + get_parameter_v2_parameters__parameter_id__get: { parameters: { query: { - /** @description Zero-based result offset */ - offset?: number; - /** @description Maximum number of returned resources */ - limit?: number; /** @description Supported PolicyEngine country ID */ country_id: 'us' | 'uk'; policyengine_version?: string | null; - parameter_id?: string | null; - /** @description Keep only these exact parameter paths' values; repeat the key */ - parameter_name?: string[] | null; - /** @description Keep only the values in effect today (UTC) */ - current?: boolean; }; header?: never; - path?: never; + path: { + parameter_id: string; + }; cookie?: never; }; requestBody?: never; @@ -7488,40 +7426,31 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['MetadataParameterValuePageResponse']; + 'application/json': components['schemas']['MetadataParameterDetailResponse']; }; }; /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -7569,17 +7498,24 @@ export interface operations { }; }; }; - get_parameter_value_v2_parameter_values__value_id__get: { + list_parameter_values_v2_parameter_values_get: { parameters: { query: { + /** @description Zero-based result offset */ + offset?: number; + /** @description Maximum number of returned resources */ + limit?: number; /** @description Supported PolicyEngine country ID */ country_id: 'us' | 'uk'; policyengine_version?: string | null; + parameter_id?: string | null; + /** @description Keep only these exact parameter paths' values; repeat the key */ + parameter_name?: string[] | null; + /** @description Keep only the values in effect today (UTC) */ + current?: boolean; }; header?: never; - path: { - value_id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -7587,40 +7523,31 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['MetadataParameterValueDetailResponse']; + 'application/json': components['schemas']['MetadataParameterValuePageResponse']; }; }; /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -7668,19 +7595,17 @@ export interface operations { }; }; }; - list_datasets_v2_datasets_get: { + get_parameter_value_v2_parameter_values__value_id__get: { parameters: { query: { - /** @description Zero-based result offset */ - offset?: number; - /** @description Maximum number of returned resources */ - limit?: number; /** @description Supported PolicyEngine country ID */ country_id: 'us' | 'uk'; policyengine_version?: string | null; }; header?: never; - path?: never; + path: { + value_id: string; + }; cookie?: never; }; requestBody?: never; @@ -7688,40 +7613,31 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['MetadataDatasetPageResponse']; + 'application/json': components['schemas']['MetadataParameterValueDetailResponse']; }; }; /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -7769,17 +7685,19 @@ export interface operations { }; }; }; - get_dataset_v2_datasets__dataset_id__get: { + list_datasets_v2_datasets_get: { parameters: { query: { + /** @description Zero-based result offset */ + offset?: number; + /** @description Maximum number of returned resources */ + limit?: number; /** @description Supported PolicyEngine country ID */ country_id: 'us' | 'uk'; policyengine_version?: string | null; }; header?: never; - path: { - dataset_id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -7787,40 +7705,31 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['MetadataDatasetDetailResponse']; + 'application/json': components['schemas']['MetadataDatasetPageResponse']; }; }; /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -7868,21 +7777,17 @@ export interface operations { }; }; }; - list_regions_v2_regions_get: { + get_dataset_v2_datasets__dataset_id__get: { parameters: { query: { - /** @description Zero-based result offset */ - offset?: number; - /** @description Maximum number of returned resources */ - limit?: number; /** @description Supported PolicyEngine country ID */ country_id: 'us' | 'uk'; policyengine_version?: string | null; - /** @description Keep only regions of this type */ - region_type?: components['schemas']['MetadataRegionType'] | null; }; header?: never; - path?: never; + path: { + dataset_id: string; + }; cookie?: never; }; requestBody?: never; @@ -7890,40 +7795,31 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['MetadataRegionPageResponse']; + 'application/json': components['schemas']['MetadataDatasetDetailResponse']; }; }; /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -7971,17 +7867,21 @@ export interface operations { }; }; }; - get_region_by_code_v2_regions_by_code__region_code__get: { + list_regions_v2_regions_get: { parameters: { query: { + /** @description Zero-based result offset */ + offset?: number; + /** @description Maximum number of returned resources */ + limit?: number; /** @description Supported PolicyEngine country ID */ country_id: 'us' | 'uk'; policyengine_version?: string | null; + /** @description Keep only regions of this type */ + region_type?: components['schemas']['MetadataRegionType'] | null; }; header?: never; - path: { - region_code: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -7989,40 +7889,31 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['MetadataRegionDetailResponse']; + 'application/json': components['schemas']['MetadataRegionPageResponse']; }; }; /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -8070,7 +7961,7 @@ export interface operations { }; }; }; - get_region_v2_regions__region_id__get: { + get_region_by_code_v2_regions_by_code__region_code__get: { parameters: { query: { /** @description Supported PolicyEngine country ID */ @@ -8079,7 +7970,7 @@ export interface operations { }; header?: never; path: { - region_id: string; + region_code: string; }; cookie?: never; }; @@ -8088,11 +7979,11 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; @@ -8103,25 +7994,16 @@ export interface operations { /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -8169,7 +8051,7 @@ export interface operations { }; }; }; - get_economy_options_v2_economy_options_get: { + get_region_v2_regions__region_id__get: { parameters: { query: { /** @description Supported PolicyEngine country ID */ @@ -8177,7 +8059,9 @@ export interface operations { policyengine_version?: string | null; }; header?: never; - path?: never; + path: { + region_id: string; + }; cookie?: never; }; requestBody?: never; @@ -8185,40 +8069,31 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['MetadataEconomyOptionsResponse']; + 'application/json': components['schemas']['MetadataRegionDetailResponse']; }; }; /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -8266,7 +8141,7 @@ export interface operations { }; }; }; - get_model_catalog_v2_model_catalog_get: { + get_economy_options_v2_economy_options_get: { parameters: { query: { /** @description Supported PolicyEngine country ID */ @@ -8282,40 +8157,31 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['MetadataModelCatalogResponse']; + 'application/json': components['schemas']['MetadataEconomyOptionsResponse']; }; }; /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -8363,7 +8229,7 @@ export interface operations { }; }; }; - get_catalog_variables_v2_model_catalog_variables_get: { + get_model_catalog_v2_model_catalog_get: { parameters: { query: { /** @description Supported PolicyEngine country ID */ @@ -8379,40 +8245,31 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['MetadataCatalogVariablesResponse']; + 'application/json': components['schemas']['MetadataModelCatalogResponse']; }; }; /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -8460,14 +8317,12 @@ export interface operations { }; }; }; - get_catalog_parameters_v2_model_catalog_parameters_get: { + get_catalog_variables_v2_model_catalog_variables_get: { parameters: { query: { /** @description Supported PolicyEngine country ID */ country_id: 'us' | 'uk'; policyengine_version?: string | null; - /** @description Exact parameter paths; repeat the key for each one */ - name: string[]; }; header?: never; path?: never; @@ -8478,40 +8333,31 @@ export interface operations { /** @description Successful Response */ 200: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['MetadataCatalogParametersResponse']; + 'application/json': components['schemas']['MetadataCatalogVariablesResponse']; }; }; /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { - /** @description Strong validator: a hash of the exact response body. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description Includes Origin, so caches keep responses apart by origin. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The country in the path is not supported. Query parameters are checked first: an invalid one, including the PolicyEngine.py version, returns 422. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -8559,12 +8405,14 @@ export interface operations { }; }; }; - get_parameter_search_file_v2_parameters_search_file_get: { + get_catalog_parameters_v2_model_catalog_parameters_get: { parameters: { query: { /** @description Supported PolicyEngine country ID */ country_id: 'us' | 'uk'; policyengine_version?: string | null; + /** @description Exact parameter paths; repeat the key for each one */ + name: string[]; }; header?: never; path?: never; @@ -8572,39 +8420,34 @@ export interface operations { }; requestBody?: never; responses: { - /** @description The catalog version's parameter search file, gzip-encoded when the request accepts gzip. */ + /** @description Successful Response */ 200: { headers: { - /** @description Strong tag of this representation: the SHA-256 of the uncompressed JSON, with "-gzip" appended for the gzip-encoded one. */ + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ ETag?: string; - /** @description public, max-age=86400 for a named policyengine_version; public, max-age=300 for the running version. */ + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ 'Cache-Control'?: string; - /** @description gzip when the request accepts gzip; absent otherwise. */ - 'Content-Encoding'?: string; - /** @description Accept-Encoding. */ + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ Vary?: string; [name: string]: unknown; }; content: { - 'application/json': components['schemas']['MetadataParameterSearchFileContent']; + 'application/json': components['schemas']['MetadataCatalogParametersResponse']; }; }; - /** @description If-None-Match names this file's content; the response has no body. */ + /** @description Not modified: If-None-Match names the current ETag. No body is sent. */ 304: { headers: { + /** @description Strong validator: a hash of the uncompressed response body, so a gzip-encoded response carries the same tag. */ + ETag?: string; + /** @description public, with max-age 86400 when the request names policyengine_version and 300 otherwise; current parameter values expire at the next UTC midnight. */ + 'Cache-Control'?: string; + /** @description Origin, Accept-Encoding: caches keep responses apart by origin and by compression; a 304 carries the same value. */ + Vary?: string; [name: string]: unknown; }; content?: never; }; - /** @description The resource request or PolicyEngine.py version is invalid. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['V2ErrorResponse']; - }; - }; /** @description The requested catalog resource is absent. */ 404: { headers: { @@ -8623,7 +8466,7 @@ export interface operations { 'application/json': components['schemas']['V2ErrorResponse']; }; }; - /** @description The request parameters do not match the resource schema. */ + /** @description The query parameters do not match the resource schema, for example an unknown parameter or a malformed PolicyEngine.py version. */ 422: { headers: { [name: string]: unknown; From d9e6ee14698cb232eb3284c7886c79dc7d96399d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:30:34 +0400 Subject: [PATCH 24/28] Count the v2 policy tree's folders by what a draft can add, from the search file The on-demand browser printed each folder's `child_count` from `/v2/parameters/children`, which counts the folder's direct children a reform can change, and hid only folders with none. v1 counts the parameters a draft can add at or below a folder and hides folders with none. Each folder's count now comes from the addable parameters in the search file the app already downloads (editable on today's date), by path prefix, so the counts match v1 and a folder with nothing to add is hidden. The browser shows only once the search file is in, since the add bar waits for it, so a count is never unknown. The test fixture now gives the Public API's direct-child counts, and holds a folder whose only parameter a draft cannot edit. Co-Authored-By: Claude Opus 5.5 --- .../policyBrowser/V3PolicyBrowser.tsx | 12 ++++---- app/src/libs/flagship/policyTreeIndex.ts | 19 ++++++++++++ .../api/publicApi/parameterChildrenMocks.ts | 22 ++++++++++---- .../policyBrowser/V3PolicyBrowserV2.test.tsx | 30 +++++++++++++++++++ .../libs/flagship/policyTreeIndex.test.ts | 25 +++++++++++++++- 5 files changed, 97 insertions(+), 11 deletions(-) diff --git a/app/src/components/flagship/policyBrowser/V3PolicyBrowser.tsx b/app/src/components/flagship/policyBrowser/V3PolicyBrowser.tsx index ec990c37f..b96d24ea0 100644 --- a/app/src/components/flagship/policyBrowser/V3PolicyBrowser.tsx +++ b/app/src/components/flagship/policyBrowser/V3PolicyBrowser.tsx @@ -6,6 +6,7 @@ import { ErrorState, SkeletonLine, SkeletonRegion } from '@/components/flagship/ import { spacing } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useEnsureV3Parameters } from '@/hooks/useV3Parameters'; +import { addableCountsByFolder } from '@/libs/flagship/policyTreeIndex'; import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; import { selectV2CatalogVersion } from '@/reducers/metadataReducer'; import type { RootState } from '@/store'; @@ -128,11 +129,12 @@ function OnDemandPolicyBrowser({ // groups differently) falls back to the top level rather than a blank. const folderFound = trailPaths.length > 0 && trail.length === trailPaths.length; const contents = folderFound ? nodesOf(levels[levels.length - 1]) : []; - const counts = new Map( - levels.flatMap((level) => - (level.data ?? []).map((child) => [child.path, child.child_count ?? 0]) - ) - ); + // What a draft can add at or below each folder, from the search file + // (as v1 counts the whole tree): the API's child_count counts a node's + // direct children a reform can change, so it neither matches nor says + // whether anything below can be added. The browser shows only once the + // search file is in (the add bar waits for it), so the counts are known. + const counts = useMemo(() => addableCountsByFolder(addablePaths), [addablePaths]); const folders = contents.filter( (node) => node.type === 'parameterNode' && (counts.get(node.name) ?? 0) > 0 ); diff --git a/app/src/libs/flagship/policyTreeIndex.ts b/app/src/libs/flagship/policyTreeIndex.ts index eab07c8d8..27704eef4 100644 --- a/app/src/libs/flagship/policyTreeIndex.ts +++ b/app/src/libs/flagship/policyTreeIndex.ts @@ -88,3 +88,22 @@ export function folderTrail(index: PolicyTreeIndex, path: string): ParameterTree } return trail; } + +/** + * How many of `addablePaths` sit at or below each folder, by path, as + * buildPolicyTreeIndex counts the whole tree: a parameter counts for every + * folder its path runs through (`a.b[0].c` counts for `a`, `a.b` and + * `a.b[0]`). A folder missing from the map has nothing a draft can add. + */ +export function addableCountsByFolder(addablePaths: Iterable): Map { + const counts = new Map(); + for (const path of addablePaths) { + for (let index = 1; index < path.length; index += 1) { + if (path[index] === '.' || path[index] === '[') { + const folder = path.slice(0, index); + counts.set(folder, (counts.get(folder) ?? 0) + 1); + } + } + } + return counts; +} diff --git a/app/src/tests/fixtures/api/publicApi/parameterChildrenMocks.ts b/app/src/tests/fixtures/api/publicApi/parameterChildrenMocks.ts index c60c53a34..1731b3c15 100644 --- a/app/src/tests/fixtures/api/publicApi/parameterChildrenMocks.ts +++ b/app/src/tests/fixtures/api/publicApi/parameterChildrenMocks.ts @@ -31,14 +31,26 @@ function parameter(path: string, label: string): Child { }; } -/** The reform-changeable children of each node, as `/v2/parameters/children` lists them. */ +/** A reform can change it, but a draft cannot edit its value: no folder offers it. */ +export const EITC_NOT_ADDABLE = 'gov.irs.credits.eitc.phase_in_rate'; + +/** + * The reform-changeable children of each node, as `/v2/parameters/children` + * lists them. Each node's `child_count` is the API's: its direct children a + * reform can change (subfolders and parameters at that level), not the + * parameters a draft can add at or below it. + */ export const PARAMETER_CHILDREN: Record = { - gov: [node('gov.irs', 'IRS', 3)], - 'gov.irs': [node('gov.irs.credits', 'Credits', 1), node('gov.irs.deductions', 'Deductions', 2)], - 'gov.irs.credits': [node('gov.irs.credits.ctc', 'Child Tax Credit', 1)], + gov: [node('gov.irs', 'IRS', 2)], + 'gov.irs': [node('gov.irs.credits', 'Credits', 2), node('gov.irs.deductions', 'Deductions', 1)], + 'gov.irs.credits': [ + node('gov.irs.credits.ctc', 'Child Tax Credit', 1), + node('gov.irs.credits.eitc', 'Earned Income Tax Credit', 1), + ], 'gov.irs.credits.ctc': [node('gov.irs.credits.ctc.refundable', 'Refundable', 1)], 'gov.irs.credits.ctc.refundable': [parameter(CTC_FULLY_REFUNDABLE, 'Fully refundable')], - 'gov.irs.deductions': [node('gov.irs.deductions.standard', 'Standard deduction', 2)], + 'gov.irs.credits.eitc': [parameter(EITC_NOT_ADDABLE, 'Phase-in rate')], + 'gov.irs.deductions': [node('gov.irs.deductions.standard', 'Standard deduction', 1)], 'gov.irs.deductions.standard': [ node('gov.irs.deductions.standard.amount', 'Standard deduction amount', 2), ], diff --git a/app/src/tests/unit/components/flagship/policyBrowser/V3PolicyBrowserV2.test.tsx b/app/src/tests/unit/components/flagship/policyBrowser/V3PolicyBrowserV2.test.tsx index 9bc87cf05..be1b786ad 100644 --- a/app/src/tests/unit/components/flagship/policyBrowser/V3PolicyBrowserV2.test.tsx +++ b/app/src/tests/unit/components/flagship/policyBrowser/V3PolicyBrowserV2.test.tsx @@ -154,6 +154,36 @@ describe('V3PolicyBrowser with v3 on the v2 routes', () => { expect(requestsTo(fetchStub, PARAMETERS_PATH)).toHaveLength(1); }); + test('given folders then each counts what a draft can add at or below it, and one with none is hidden', async () => { + // Given: the API counts direct children (IRS has two, Credits two, + // one of them EITC, whose only parameter a draft cannot edit). + const user = userEvent.setup(); + releaseFirstLevel(); + render( + + + + ); + + // Then: the three addable parameters under IRS, as v1 counts them. + const irs = await screen.findByRole('button', { name: 'Open IRS' }); + expect(irs).toHaveTextContent('3 parameters'); + + // When + await user.click(irs); + + // Then + expect(await screen.findByRole('button', { name: 'Open Credits' })).toHaveTextContent('1'); + expect(screen.getByRole('button', { name: 'Open Deductions' })).toHaveTextContent('2'); + await user.click(screen.getByRole('button', { name: 'Open Credits' })); + expect( + await screen.findByRole('button', { name: 'Open Child Tax Credit' }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Open Earned Income Tax Credit' }) + ).not.toBeInTheDocument(); + }); + test('given a parameter is added then the browser hands its path on', async () => { const user = userEvent.setup(); const onAdd = vi.fn(); diff --git a/app/src/tests/unit/libs/flagship/policyTreeIndex.test.ts b/app/src/tests/unit/libs/flagship/policyTreeIndex.test.ts index 51bfcae38..1000f6bf9 100644 --- a/app/src/tests/unit/libs/flagship/policyTreeIndex.test.ts +++ b/app/src/tests/unit/libs/flagship/policyTreeIndex.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from 'vitest'; -import { buildPolicyTreeIndex, folderContents, folderTrail } from '@/libs/flagship/policyTreeIndex'; +import { + addableCountsByFolder, + buildPolicyTreeIndex, + folderContents, + folderTrail, +} from '@/libs/flagship/policyTreeIndex'; import { DRAFTABLE, POLICY_TREE } from '@/tests/fixtures/components/flagship/policyTreeFixtures'; describe('buildPolicyTreeIndex', () => { @@ -58,3 +63,21 @@ describe('folderTrail', () => { ]); }); }); + +describe('addableCountsByFolder', () => { + test('given addable paths then each folder they run through counts them, brackets included', () => { + const counts = addableCountsByFolder([ + 'gov.irs.credits.ctc.amount.base[0].amount', + 'gov.irs.credits.ctc.amount.base[1].amount', + 'gov.irs.deductions.standard.amount.SINGLE', + ]); + + expect(counts.get('gov')).toBe(3); + expect(counts.get('gov.irs.credits.ctc.amount.base')).toBe(2); + expect(counts.get('gov.irs.credits.ctc.amount.base[0]')).toBe(1); + expect(counts.get('gov.irs.deductions.standard.amount')).toBe(1); + // A parameter is not a folder of itself, and a folder with nothing is absent. + expect(counts.has('gov.irs.deductions.standard.amount.SINGLE')).toBe(false); + expect(counts.has('gov.hmrc')).toBe(false); + }); +}); From 1adbd8dc9085d7e9934bcccc4c1a24307d28ddba Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:35:47 +0400 Subject: [PATCH 25/28] Keep the v1 failure displays exactly as today, showing the new ones only on v2 With the switch off, some failures showed differently from today: - The Reforms page read any failed parameter status as a failure, so on a v1 metadata failure the bill detail replaced its provisions with "Could not load the parameters" and disabled its actions, and the saved-reform detail disabled its report button. - The Report page said "Could not load the reform policy" when a v1 policy request failed. - The household breakdowns said "Could not load policy N" instead of the request's own message, and no longer counted a failed refetch of a policy already in hand as an error. These failure displays now appear only on the v2 routes, and the household hook's v1 path is again the code it had before v3 loaded parameters on demand. Co-Authored-By: Claude Opus 5.5 --- app/src/hooks/useFlagshipHousehold.ts | 65 ++++++++++++++----- app/src/pages/flagship/Reforms.page.tsx | 6 +- app/src/pages/flagship/Report.page.tsx | 4 +- .../report/HouseholdBreakdowns.test.tsx | 41 +++++++++++- .../unit/pages/flagship/Reforms.test.tsx | 29 +++++++++ .../pages/flagship/ReportLoadStates.test.tsx | 24 +++++++ 6 files changed, 150 insertions(+), 19 deletions(-) diff --git a/app/src/hooks/useFlagshipHousehold.ts b/app/src/hooks/useFlagshipHousehold.ts index e51f99973..268f72d7d 100644 --- a/app/src/hooks/useFlagshipHousehold.ts +++ b/app/src/hooks/useFlagshipHousehold.ts @@ -1,7 +1,14 @@ import { useQuery } from '@tanstack/react-query'; +import { PolicyAdapter } from '@/adapters/PolicyAdapter'; +import { fetchPolicyById } from '@/api/policy'; +import { useParameterMetadata } from '@/hooks/useParameterMetadata'; import { useV3Policies } from '@/hooks/useV3Policies'; +import { useParallelQueries } from '@/hooks/utils/queryUtils'; import { runFlagshipHouseholdPayload } from '@/libs/flagship/householdRun'; +import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; +import { policyKeys } from '@/libs/queryKeys'; import type { V1HouseholdCreateEnvelope } from '@/models/household/v1Types'; +import type { Policy } from '@/types/ingredients/Policy'; /** * A household built in full (the household builder's model), run for a @@ -39,24 +46,41 @@ export function useFlagshipHouseholdPayload({ }); } -/** - * The report's policies in the app's model, for the v2 household - * breakdowns that send a policy's parameters with their request (earnings - * variation, marginal tax rates). Fetched as the report page fetches them, - * under the same keys, so a policy the page already holds is not fetched - * again. Nothing runs until `enabled`. - */ -export function useFlagshipHouseholdPolicies({ - countryId, - policyIds, - enabled, -}: { +interface HouseholdPoliciesArgs { countryId: string; policyIds: string[]; enabled: boolean; -}) { - // Read as stored, then parsed once the parameters each names are in, as - // the report page reads them (under the same keys). +} + +/** On the v1 routes: as before v3 loaded parameters on demand. */ +function useV1HouseholdPolicies({ countryId, policyIds, enabled }: HouseholdPoliciesArgs) { + const { parameters, isReady } = useParameterMetadata(countryId); + const results = useParallelQueries(policyIds, { + queryKey: policyKeys.byId, + queryFn: async (policyId) => + PolicyAdapter.fromMetadata(await fetchPolicyById(countryId, policyId), parameters), + enabled: enabled && isReady && policyIds.length > 0, + staleTime: 5 * 60 * 1000, + structuralSharing: false, + }); + const policies = results.queries + .map((query) => query.data) + .filter((policy): policy is Policy => !!policy); + const error = (results.error as Error | null) ?? null; + return { + policies, + /** Waiting for the model's parameters, or for a policy. */ + isLoading: enabled && !error && policies.length < new Set(policyIds).size, + error, + retry: () => Promise.all(results.queries.map((query) => query.refetch())), + }; +} + +/** + * On the v2 routes: read as stored, then parsed once the parameters each + * names are in, as the report page reads them (under the same keys). + */ +function useV2HouseholdPolicies({ countryId, policyIds, enabled }: HouseholdPoliciesArgs) { const results = useV3Policies(countryId, policyIds, enabled); const { policies } = results; const error = @@ -71,3 +95,14 @@ export function useFlagshipHouseholdPolicies({ retry: results.retry, }; } + +/** + * The report's policies in the app's model, for the v2 household + * breakdowns that send a policy's parameters with their request (earnings + * variation, marginal tax rates). Fetched as the report page fetches them, + * under the same keys, so a policy the page already holds is not fetched + * again. Nothing runs until `enabled`. The routes v3 uses (V3_API_ROUTES, + * a constant) pick the implementation. + */ +export const useFlagshipHouseholdPolicies = + V3_API_ROUTES === 'v2' ? useV2HouseholdPolicies : useV1HouseholdPolicies; diff --git a/app/src/pages/flagship/Reforms.page.tsx b/app/src/pages/flagship/Reforms.page.tsx index b8fabe810..b26f1e876 100644 --- a/app/src/pages/flagship/Reforms.page.tsx +++ b/app/src/pages/flagship/Reforms.page.tsx @@ -58,6 +58,7 @@ import { dayLabel, periodLabel } from '@/libs/flagship/draftLabels'; import { latestMatchingReport } from '@/libs/flagship/latestReport'; import { rerunSettings } from '@/libs/flagship/reportRerun'; import { flagshipReportQueryKeys } from '@/libs/flagship/runReport'; +import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; import { reformParameterNames } from '@/libs/flagship/v3ParameterNames'; import { RootState } from '@/store'; import { Reform, ReformSource } from '@/types/ingredients/Reform'; @@ -515,9 +516,10 @@ export default function ReformsPage() { ); const viewedParameters = useEnsureV3Parameters(viewedNames); // On the v2 routes a detail's actions wait for those parameters, and a - // failed load says so, with a retry. + // failed load says so, with a retry. On v1 they come with the whole + // metadata, which the details never waited for or reported. const parametersWaiting = viewedParameters.waiting; - const parametersFailed = viewedParameters.status === 'error'; + const parametersFailed = V3_API_ROUTES === 'v2' && viewedParameters.status === 'error'; const openReform = (reform: Reform) => { // A failed delete or re-run belongs to the reform it was tried on. diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 3c6d8484c..2af3fddff 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -52,6 +52,7 @@ import { } from '@/libs/flagship/reportBaseline'; import { provenanceFromPolicy } from '@/libs/flagship/reportProvenance'; import { readReportMeta, reportMetaFromRecord } from '@/libs/flagship/runReport'; +import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; import { RootState } from '@/store'; import type { CalcStartConfig } from '@/types/calculation'; import { allSimulationsLoaded } from '@/utils/reportSimulations'; @@ -554,7 +555,8 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep )} - {!meta && reformPolicyFailed && ( + {/* On the v1 routes the report shows without its changes, as before. */} + {!meta && reformPolicyFailed && V3_API_ROUTES === 'v2' && ( { const alert = await screen.findByRole('alert'); expect(alert).toHaveTextContent("The report's policies could not be loaded"); + // The request's own message, as before. + expect(alert).toHaveTextContent(`Failed to fetch policy ${REFORM_POLICY_ID}`); expect(enabledVariationCalls()).toEqual([]); await user.click(screen.getByRole('button', { name: 'Try again' })); @@ -193,6 +196,42 @@ describe('HouseholdBreakdowns', () => { }); }); +describe('useFlagshipHouseholdPolicies on the v1 routes', () => { + test('given a policy in hand whose refetch fails then it reports the error, as before', async () => { + // Given + mockFetchPolicyById.mockImplementation(async (_countryId: string, policyId: string) => ({ + id: policyId, + country_id: 'us', + api_version: 'v1', + policy_json: {}, + policy_hash: 'hash', + })); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook( + () => + useFlagshipHouseholdPolicies({ + countryId: 'us', + policyIds: [REFORM_POLICY_ID], + enabled: true, + }), + { + wrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + } + ); + await waitFor(() => expect(result.current.policies).toHaveLength(1)); + + // When + mockFetchPolicyById.mockRejectedValue(new Error('Policy service down')); + await act(() => queryClient.refetchQueries()); + + // Then + await waitFor(() => expect(result.current.error?.message).toBe('Policy service down')); + expect(result.current.policies).toHaveLength(1); + }); +}); + describe('toAppCalculationData', () => { test("given the API's snake-case groups then they take the app keys the v2 readers use", () => { const converted = toAppCalculationData({ diff --git a/app/src/tests/unit/pages/flagship/Reforms.test.tsx b/app/src/tests/unit/pages/flagship/Reforms.test.tsx index 109e2c121..270938342 100644 --- a/app/src/tests/unit/pages/flagship/Reforms.test.tsx +++ b/app/src/tests/unit/pages/flagship/Reforms.test.tsx @@ -8,6 +8,7 @@ import ReformsPage from '@/pages/flagship/Reforms.page'; import { clearMetadata } from '@/reducers/metadataReducer'; import { store } from '@/store'; import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { seedMetadataError } from '@/tests/fixtures/hooks/useFlagshipMetadataStatusMocks'; import { FEDERAL_ENACTED, FEDERAL_PROPOSED, @@ -351,6 +352,34 @@ describe('ReformsPage', () => { expect(screen.getByText(/raises the base child tax credit/i)).toBeInTheDocument(); }); + test("given v1's metadata failed then a bill's detail lists its provisions and offers its actions, as before", async () => { + // Given + seedMetadataError(); + + // When + renderReforms('/us/reforms?bill=us-ctc-expansion'); + + // Then + expect(await screen.findByRole('button', { name: /run impact report/i })).toBeEnabled(); + expect(screen.getByRole('button', { name: /open as draft reform/i })).toBeEnabled(); + expect(screen.queryByText('Could not load the parameters')).not.toBeInTheDocument(); + }); + + test("given v1's metadata failed then a saved reform's report button stays enabled, as before", async () => { + // Given + const user = userEvent.setup(); + seedMetadataError(); + mockFindMine.mockResolvedValue([CHAT_REFORM]); + renderReforms('/us/reforms?filter=yours'); + + // When + await user.click(await screen.findByText('CTC to $3,600 for children under 6')); + + // Then + expect(screen.getByRole('button', { name: /view full impact report/i })).toBeEnabled(); + expect(screen.queryByText('Could not load the parameters')).not.toBeInTheDocument(); + }); + test('given a ?bill deep link then the detail opens directly', async () => { renderReforms('/us/reforms?bill=us-ctc-expansion'); diff --git a/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx b/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx index a32ed42c2..ffa9eb0fa 100644 --- a/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx +++ b/app/src/tests/unit/pages/flagship/ReportLoadStates.test.tsx @@ -161,6 +161,30 @@ describe('FlagshipReportPage load states', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); + test('given the reform policy failed on the v1 routes then the report shows as before, with no message for it', () => { + // Given + mockCalcStatus.mockReturnValue({ + status: 'complete', + isComplete: true, + isError: false, + result: createMockSocietyWideOutput(), + }); + mockUseFlagshipReport.mockReturnValue( + reportData({ + report: EXISTING_REPORT, + simulations: EXISTING_SIMULATIONS, + failedPolicyIds: [String(EXISTING_SIMULATIONS[1].policyId)], + }) + ); + + // When + renderPage(EXISTING_REPORT_ID); + + // Then + expect(screen.queryByText('Could not load the reform policy')).not.toBeInTheDocument(); + expect(screen.queryByRole('status', { name: 'Loading report' })).not.toBeInTheDocument(); + }); + test('given a link that names no report then it says not found, with no retry', () => { mockUseFlagshipReport.mockReturnValue( reportData({ error: REPORT_NOT_FOUND_ERROR, notFound: true }) From 803704166853d2cc73521e018dcecae4807e1e4a Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:39:24 +0400 Subject: [PATCH 26/28] Give the chat's draft step to the answer it came from, even after a new question The chat's validated reform loads its parameters before it can join the draft, and the step then updated whichever turn was newest. A question asked during that load received the earlier answer's draft step, and the earlier turn kept saying it was loading. The step now updates the turn its question started, as its retry already did. Co-Authored-By: Claude Opus 5.5 --- app/src/pages/flagship/Ask.page.tsx | 5 +- .../tests/unit/pages/flagship/AskV2.test.tsx | 56 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/app/src/pages/flagship/Ask.page.tsx b/app/src/pages/flagship/Ask.page.tsx index 029556485..17c7662f2 100644 --- a/app/src/pages/flagship/Ask.page.tsx +++ b/app/src/pages/flagship/Ask.page.tsx @@ -251,6 +251,9 @@ export default function AskPage() { abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; + // The turn this question starts: work that finishes after another + // question (the reform's draft step) still belongs to it. + const turnIndex = turns.length; const history: AskChatMessage[] = turns.flatMap((turn) => { const messages: AskChatMessage[] = [{ role: 'user', content: turn.question }]; @@ -303,7 +306,7 @@ export default function AskPage() { onToolUse: ({ toolName, toolInput }) => { const reform = reformFromToolInput(toolName, toolInput); if (reform) { - void bridgeChatReform(reform, patchLastChat); + void bridgeChatReform(reform, patchChatAt(turnIndex)); } }, onSuggestions: (suggestions) => patchLastChat((chat) => ({ ...chat, suggestions })), diff --git a/app/src/tests/unit/pages/flagship/AskV2.test.tsx b/app/src/tests/unit/pages/flagship/AskV2.test.tsx index 478a4cd88..d63585dbc 100644 --- a/app/src/tests/unit/pages/flagship/AskV2.test.tsx +++ b/app/src/tests/unit/pages/flagship/AskV2.test.tsx @@ -254,4 +254,60 @@ describe('AskPage with v3 on the v2 routes', () => { expect(screen.queryByRole('status')).not.toBeInTheDocument(); expect(requestsTo(fetchStub, SEARCH_FILE_PATH)).toHaveLength(2); }); + + test("given a new question while the chat's reform loads then the reform's draft step joins its own answer", async () => { + // Given: the first answer's reform waits for its parameters. + vi.stubEnv('NEXT_PUBLIC_US_ASK', 'on'); + mockStreamAskChatTurn + .mockImplementationOnce(async (_request, handlers: AskChatHandlers) => { + handlers.onToolUse?.({ + toolName: 'run_society_simulation', + toolId: 't1', + toolInput: { reform: { [STANDARD_DEDUCTION_SINGLE]: 20000 }, year: 2026 }, + }); + handlers.onDone?.({ content: 'First answer.', sessionId: 's1' }); + }) + .mockImplementationOnce(async (_request, handlers: AskChatHandlers) => { + handlers.onDone?.({ content: 'Second answer.', sessionId: 's1' }); + }); + let releaseParameters: () => void = () => {}; + const held = new Promise((resolve) => { + releaseParameters = resolve; + }); + stubPublicApiFetch({ + [SEARCH_FILE_PATH]: () => jsonResponse(US_SEARCH_FILE), + [PARAMETERS_PATH]: async (url) => { + await held; + return answerParameters(url); + }, + }); + await seedV2ModelCatalog(); + const user = userEvent.setup(); + await renderAskWithSearchFile(); + await ask(user, 'Raise the standard deduction for single filers to $20,000'); + expect(await screen.findByRole('status')).toHaveTextContent( + 'Loading current law for this reform' + ); + + // When + await ask(user, 'What else could I change?'); + await screen.findByText('Second answer.'); + releaseParameters(); + + // Then: the first turn's step is in, and nothing is left loading. + expect(await screen.findByRole('button', { name: /add to draft/i })).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + const [firstAnswer, secondAnswer] = [ + screen.getByText('First answer.'), + screen.getByText('Second answer.'), + ]; + const step = screen.getByText('Reform validated by the model'); + // In document order: first answer, its step, then the second answer. + expect( + firstAnswer.compareDocumentPosition(step) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + step.compareDocumentPosition(secondAnswer) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); }); From 0641d8c22d6a6c49683869f17fdc9a0543559495 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:44:16 +0400 Subject: [PATCH 27/28] Use the generated bodies for saved reforms in both stores, and check API values where they arrive Both reform stores now build their `/v2/user-policies` bodies from the generated UserPolicyCreateRequest and UserPolicyPatchRequest types. The hand-written bodies and their policy-reference union are gone, along with the type test of that union; the v1 store sends the same fields as before. A parameter value the Public API sends is typed only as JSON. The catalog parameters and a v2 policy's values are now checked where they arrive, and a value that is not a parameter value fails there, naming its parameter, instead of being cast to the app's value type. Values a reform sends to `/v2/policies` are checked as finite JSON before the request. Co-Authored-By: Claude Opus 5.5 --- app/src/api/publicApi/catalogParameters.ts | 12 ++-- app/src/api/publicApi/jsonValues.ts | 57 +++++++++++++++++++ app/src/api/reformStore.ts | 38 +++++++------ .../api/publicApi/catalogParameters.test.ts | 24 ++++++++ app/src/tests/unit/api/reformStoreV2.test.ts | 38 +++++++++++++ app/src/tests/unit/types/v2User.test.ts | 23 -------- app/src/types/v2User.ts | 26 +-------- 7 files changed, 150 insertions(+), 68 deletions(-) create mode 100644 app/src/api/publicApi/jsonValues.ts delete mode 100644 app/src/tests/unit/types/v2User.test.ts diff --git a/app/src/api/publicApi/catalogParameters.ts b/app/src/api/publicApi/catalogParameters.ts index 8a7bc6e98..28c195271 100644 --- a/app/src/api/publicApi/catalogParameters.ts +++ b/app/src/api/publicApi/catalogParameters.ts @@ -4,6 +4,7 @@ import { requireResult, type PublicApiClient, } from '@/api/publicApi/client'; +import { parameterValueFromApi } from '@/api/publicApi/jsonValues'; import type { components } from '@/api/publicApi/schema'; import { v1FallbackLabel } from '@/libs/flagship/v2ParameterSearch'; import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; @@ -35,13 +36,16 @@ export interface LoadedCatalogParameters { const V2_EARLIEST_DATE = '0001-01-01'; const V1_EARLIEST_DATE = '0000-01-01'; -function v1Values(values: Schemas['MetadataDatedValue'][]): ValuesList { +function v1Values(parameterName: string, values: Schemas['MetadataDatedValue'][]): ValuesList { return Object.fromEntries( values.map((dated) => { const date = dated.start_date.slice(0, 10); - return [date === V2_EARLIEST_DATE ? V1_EARLIEST_DATE : date, dated.value]; + return [ + date === V2_EARLIEST_DATE ? V1_EARLIEST_DATE : date, + parameterValueFromApi(dated.value, parameterName), + ]; }) - ) as ValuesList; + ); } function addResult( @@ -62,7 +66,7 @@ function addResult( label: parameter.label ?? v1FallbackLabel(parameter.name), description: parameter.description, unit: parameter.unit, - values: v1Values(parameter.values), + values: v1Values(parameter.name, parameter.values), economy: parameter.economy ?? true, household: parameter.household ?? true, }; diff --git a/app/src/api/publicApi/jsonValues.ts b/app/src/api/publicApi/jsonValues.ts new file mode 100644 index 000000000..48ad3da06 --- /dev/null +++ b/app/src/api/publicApi/jsonValues.ts @@ -0,0 +1,57 @@ +import type { components } from '@/api/publicApi/schema'; +import type { PolicyParameterValue } from '@/types/metadata/policyMetadata'; + +/** A value `/v2/policies` takes: JSON whose numbers are finite. */ +export type FiniteJsonValue = components['schemas']['FiniteJsonValue']; + +/** + * Whether a value is JSON made of the kinds the app keeps parameter values + * as: text, numbers, yes/no values, null, and lists and plain objects of + * them. With `finite`, every number must be finite. + */ +function isJsonValue(value: unknown, finite: boolean): boolean { + if (value === null || typeof value === 'string' || typeof value === 'boolean') { + return true; + } + if (typeof value === 'number') { + return finite ? Number.isFinite(value) : !Number.isNaN(value); + } + if (Array.isArray(value)) { + return value.every((item) => isJsonValue(item, finite)); + } + if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) { + return Object.values(value).every((item) => isJsonValue(item, finite)); + } + return false; +} + +function isParameterValue(value: unknown): value is PolicyParameterValue { + return isJsonValue(value, false); +} + +function isFiniteJsonValue(value: unknown): value is FiniteJsonValue { + return isJsonValue(value, true); +} + +/** + * A parameter value the Public API sent (typed only as JSON), checked where + * it arrives so a malformed response fails there, naming the parameter. + */ +export function parameterValueFromApi(value: unknown, parameterName: string): PolicyParameterValue { + if (!isParameterValue(value)) { + throw new Error( + `The Public API sent a value for ${parameterName} that is not a parameter value.` + ); + } + return value; +} + +/** A parameter value as `/v2/policies` takes it, checked before it is sent. */ +export function finiteJsonValueForApi(value: unknown, parameterName: string): FiniteJsonValue { + if (!isFiniteJsonValue(value)) { + throw new Error( + `The value for ${parameterName} cannot be sent to the Public API as finite JSON.` + ); + } + return value; +} diff --git a/app/src/api/reformStore.ts b/app/src/api/reformStore.ts index 3b208abfa..c9deff73c 100644 --- a/app/src/api/reformStore.ts +++ b/app/src/api/reformStore.ts @@ -6,6 +6,7 @@ import { requireResult, type PublicApiClient, } from '@/api/publicApi/client'; +import { finiteJsonValueForApi, parameterValueFromApi } from '@/api/publicApi/jsonValues'; import type { components } from '@/api/publicApi/schema'; import { AccountAssociationRoutes, @@ -24,14 +25,17 @@ import type { ParameterMetadataCollection } from '@/types/metadata/parameterMeta import type { PolicyMetadataParams } from '@/types/metadata/policyMetadata'; import { Parameter } from '@/types/subIngredients/parameter'; import { ValueInterval } from '@/types/subIngredients/valueInterval'; -import type { - ProvenanceWire, - UserPolicyCreateBody, - UserPolicyItem, - UserPolicyPatchBody, -} from '@/types/v2User'; +import type { ProvenanceWire, UserPolicyItem } from '@/types/v2User'; import { coercePolicyParameterValue, resolvePolicyParameterValueSpec } from '@/utils/valueCoercion'; +/** + * The `/v2/user-policies` bodies, as the Public API's OpenAPI document gives + * them. The API refuses a row that names neither `policy_id` nor + * `legacy_policy_id`. + */ +type UserPolicyCreateRequest = components['schemas']['UserPolicyCreateRequest']; +type UserPolicyPatchRequest = components['schemas']['UserPolicyPatchRequest']; + /** * Store for the flagship app's reform objects. * @@ -316,7 +320,7 @@ export class ApiReformStore implements ReformStore { } /** The row for a reform; its parameters become a v1 policy first when they are not one yet. */ - private async creationBody(reform: NewReform): Promise { + private async creationBody(reform: NewReform): Promise { const countryId = this.routes.writeCountry(reform.countryId); const legacyPolicyId = isLegacyId(reform.policyId) ? Number(reform.policyId) @@ -366,7 +370,7 @@ export class ApiReformStore implements ReformStore { if (!isAccountId(reformId)) { throw new BrowserOnlyIdError(); } - const body: UserPolicyPatchBody = {}; + const body: UserPolicyPatchRequest = {}; if (updates.label !== undefined) { body.name = clampStoreText(updates.label) || null; } @@ -422,9 +426,6 @@ export class ApiReformStore implements ReformStore { type V2PolicyItem = components['schemas']['PolicyItem']; type V2PolicyCreateBody = components['schemas']['PolicyCreateRequest']; -/** The `/v2/user-policies` bodies, as the Public API's OpenAPI document gives them. */ -type UserPolicyCreateRequest = components['schemas']['UserPolicyCreateRequest']; -type UserPolicyPatchRequest = components['schemas']['UserPolicyPatchRequest']; /** A v2 policy's JSON value: infinities as the API spells them (JSON has none). */ function v2PolicyValue(value: unknown): unknown { @@ -473,11 +474,14 @@ export function v2ReformPolicyBody( : null; return parameter.values.map((interval) => ({ parameter_id: parameterId, - value: v2PolicyValue( - valueSpec - ? coercePolicyParameterValue(interval.value, valueSpec, parameter.name) - : interval.value - ) as V2PolicyCreateBody['parameter_values'][number]['value'], + value: finiteJsonValueForApi( + v2PolicyValue( + valueSpec + ? coercePolicyParameterValue(interval.value, valueSpec, parameter.name) + : interval.value + ), + parameter.name + ), start_date: utcMidnight(interval.startDate), end_date: utcMidnight(interval.endDate), })); @@ -544,7 +548,7 @@ export function parametersFromV2Policy( intervals.push({ startDate: parameterValue.start_date.slice(0, 10), endDate: parameterValue.end_date ? parameterValue.end_date.slice(0, 10) : FOREVER, - value: parameterValue.value as ValueInterval['value'], + value: parameterValueFromApi(parameterValue.value, parameterValue.parameter_name), }); byName.set(parameterValue.parameter_name, intervals); } diff --git a/app/src/tests/unit/api/publicApi/catalogParameters.test.ts b/app/src/tests/unit/api/publicApi/catalogParameters.test.ts index a1f2a4313..7e019cb0b 100644 --- a/app/src/tests/unit/api/publicApi/catalogParameters.test.ts +++ b/app/src/tests/unit/api/publicApi/catalogParameters.test.ts @@ -83,6 +83,30 @@ describe('fetchCatalogParameters', () => { }); }); + test('given a dated value that is not a parameter value then the load fails, naming the parameter', async () => { + // Given: a value missing from the response. + const fetchMock = vi.fn(async () => { + const result = structuredClone(catalogParametersResult([STANDARD_DEDUCTION_SINGLE])); + delete (result.parameters[0].values[0] as { value?: unknown }).value; + return jsonResponse({ status: 'ok', message: null, result }); + }); + const client = createPublicApiClient({ + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + // When + const loading = fetchCatalogParameters( + 'us', + TEST_POLICYENGINE_VERSION, + [STANDARD_DEDUCTION_SINGLE], + client + ); + + // Then + await expect(loading).rejects.toThrow(STANDARD_DEDUCTION_SINGLE); + }); + test('given the ancestors labels then breadcrumbs read them, with v1 labels for the unlabelled', async () => { const { client } = catalogClient(); diff --git a/app/src/tests/unit/api/reformStoreV2.test.ts b/app/src/tests/unit/api/reformStoreV2.test.ts index 3731a2ea2..f559b75d1 100644 --- a/app/src/tests/unit/api/reformStoreV2.test.ts +++ b/app/src/tests/unit/api/reformStoreV2.test.ts @@ -229,6 +229,44 @@ describe('parametersFromV2Policy', () => { }); }); +describe('the values a v2 policy sends and reads', () => { + it('given a stored value that is not a parameter value then reading the policy fails, naming the parameter', () => { + const policy = v2Policy(); + delete (policy.parameter_values[0] as { value?: unknown }).value; + + expect(() => parametersFromV2Policy(policy)).toThrow(CTC_AMOUNT); + }); + + it('given a value that is not finite JSON then the policy is not sent, naming the parameter', async () => { + // Given + const fetchMock = vi.fn(); + const client = createPublicApiClient({ + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const parameters = [ + { + name: CTC_AMOUNT, + values: [{ startDate: '2026-01-01', endDate: FOREVER, value: Number.NaN }], + }, + ]; + + // When + const creating = createV2ReformPolicy( + 'us', + parameters, + CATALOG, + PARAMETER_IDS, + undefined, + client + ); + + // Then + await expect(creating).rejects.toThrow(CTC_AMOUNT); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + describe('ApiV2ReformStore', () => { beforeEach(() => { localStorage.clear(); diff --git a/app/src/tests/unit/types/v2User.test.ts b/app/src/tests/unit/types/v2User.test.ts deleted file mode 100644 index d6882774c..000000000 --- a/app/src/tests/unit/types/v2User.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import type { UserPolicyCreateBody } from '@/types/v2User'; - -const V2_POLICY_ID = 'a1b2c3d4-0000-4000-8000-000000000001'; -const V1_POLICY_ID = 95762; - -describe('UserPolicyCreateBody', () => { - // Checked by `bun run typecheck`: each body below compiles only if the - // type requires a row to name a policy, as the API does. - test('given a saved reform row then it names a v2 policy, a v1 policy or both', () => { - // @ts-expect-error A row must name policy_id or legacy_policy_id. - const withoutPolicy: UserPolicyCreateBody = { country_id: 'us' }; - const onV2Policy: UserPolicyCreateBody = { country_id: 'us', policy_id: V2_POLICY_ID }; - const onV1Policy: UserPolicyCreateBody = { country_id: 'us', legacy_policy_id: V1_POLICY_ID }; - const onLinkedPolicies: UserPolicyCreateBody = { - country_id: 'us', - policy_id: V2_POLICY_ID, - legacy_policy_id: V1_POLICY_ID, - }; - - expect([withoutPolicy, onV2Policy, onV1Policy, onLinkedPolicies]).toHaveLength(4); - }); -}); diff --git a/app/src/types/v2User.ts b/app/src/types/v2User.ts index 493485163..3ae6e4832 100644 --- a/app/src/types/v2User.ts +++ b/app/src/types/v2User.ts @@ -136,30 +136,8 @@ export interface SharedReportItem { /* Create and patch bodies (strict on the server: unknown keys are a 400). */ -/** - * The policy a saved row points at. The API takes a v2 policy - * (`policy_id`, from `/v2/policies`), a v1 policy (`legacy_policy_id`, - * from `POST /{country}/policy`), or both when they name the same policy; - * a row with neither is refused. - */ -export type UserPolicyReference = - | { policy_id: string; legacy_policy_id?: number } - | { policy_id?: string; legacy_policy_id: number }; - -export type UserPolicyCreateBody = UserPolicyReference & { - country_id: AssociationCountryId; - name?: string | null; - description?: string | null; - baseline?: BaselineWire | null; - provenance?: ProvenanceWire | null; -}; - -export type UserPolicyPatchBody = Partial< - Pick< - UserPolicyCreateBody, - 'policy_id' | 'legacy_policy_id' | 'name' | 'description' | 'baseline' | 'provenance' - > ->; +// The `/v2/user-policies` bodies are the generated UserPolicyCreateRequest +// and UserPolicyPatchRequest (see api/reformStore.ts). export interface UserReportCreateBody { country_id: AssociationCountryId; From 8793eba4fc9eb241625fc784ace879d783ed1698 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:47:01 +0400 Subject: [PATCH 28/28] Regenerate the lockfile with the pinned Bun 1.2.21 bun.lock had been written by a newer Bun than the repository's pinned bun@1.2.21, which added a `configVersion` field and dropped the optional `@edge-runtime` entries. It is now written by bun@1.2.21 from the base lockfile, so it changes only to add openapi-fetch and openapi-typescript and the packages they depend on. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 6709326f0..1d684e841 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "policyengine-monorepo", @@ -270,6 +269,10 @@ "@dual-bundle/import-meta-resolve": ["@dual-bundle/import-meta-resolve@4.2.1", "", {}, "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg=="], + "@edge-runtime/primitives": ["@edge-runtime/primitives@4.1.0", "", {}, "sha512-Vw0lbJ2lvRUqc7/soqygUX216Xb8T3WBZ987oywz6aJqRxcwSVWwr9e+Nqo2m9bxobA9mdbWNNoRY6S9eko1EQ=="], + + "@edge-runtime/vm": ["@edge-runtime/vm@3.2.0", "", { "dependencies": { "@edge-runtime/primitives": "4.1.0" } }, "sha512-0dEVyRLM/lG4gp1R/Ik5bfPl/1wX00xFwd5KcNH602tzBa09oF7pbTKETEhR1GjZ75K6OJnYFu8II2dyMhONMw=="], + "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],