Repository navigation
Conversation
Add the user_identities table and reshape the four user association tables so each row can reference a v1 artifact, a v2 record, or both, with a per-user client_ref for idempotent imports. Simulation and report associations lose their cross-service foreign keys to v2 simulations and reports. One autogenerated v2 revision (fbb7488ebe8a) covers both parts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port the Simulation Entrypoint JWTDecoder (PyJWT PyJWKClient, RS256, iss/aud) into libs/user_auth.py with issuer normalization and client principal classification, plus an optional Auth0 Management API client for account deletion. Validate PUBLIC_API_AUTH_* and AUTH0_MANAGEMENT_* settings in the readiness gate, map authentication failures to the v2 error envelope with WWW-Authenticate, and expose that header via CORS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve (and on first use provision) the caller's v2 user from the bearer token through user_identities, with savepoint race recovery on UNIQUE(issuer, subject). Remove user_id from association requests and queries, scope every read and write to the owner (404 otherwise), and accept legacy v1 references checked through a batched, read-only v1 connector (422 when missing). Repointing re-resolves or clears the v2 reference, and rows owned by the Stage 10 mirror return 409. Add /v2/user-simulations, /v2/user-reports (with the v3 report fields), and /v2/me (GET, PATCH, DELETE, export, idempotent imports). The Stage 10 projection now records legacy_policy_id. require_user is a fail-closed NativeRouteDependencies field attached at router level. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add user-simulations, user-reports, and me path segments to the migration registry; give ContractRequest a required-headers field; record authenticated variants of the user-policy and user-household contracts and new simulation, report, and account contracts; guard that user-owned routes always require Authorization; and regenerate the contract artifacts. Add disposable-Postgres tests for concurrent first sign-in, concurrent idempotent imports, MATCH SIMPLE keys, and account deletion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Report the four v2 user-association row counts through SQLAlchemy in a read-only, rolled-back transaction, and stop when the Stage 12A revision is pending while user_simulation_associations or user_report_associations hold rows. The seed workflow runs it before the schema upgrade. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…utes Pass PUBLIC_API_AUTH_* and the optional AUTH0_MANAGEMENT_* settings through the staging and production deploy jobs and the Cloud Run candidate script, validate them before deploying, and keep them out of the release test environment. Rewrite the live staging probe to sign in a dedicated staging Auth0 test user and exercise /v2/me, all four association resources, imports, export, and account deletion without direct database access. Both candidate smoke suites now assert that anonymous user-route requests return 401. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the Stage 12A migration guide (routes, provisioning, schema revision, settings, GitHub variables and secrets, Auth0 prerequisites, rollout, verification), cross-reference it from the Stage 10 and 11 guides, add Stage 12A test commands to the testing skill, record a changelog fragment, and add a doc-only, preview-first Cloud Armor proposal for signed-in user writes and imports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt test Fold the new auth unsets into the existing release-test command line so the workflow keeps its three-line inline limit, and move the Stage 11 household reassignment integration test to the signed-in contract, repointing through a mapped legacy household. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 12, 2026
Open
… rows The Stage 12A revision dropped country and label from the dormant simulation and report association tables, but the previous Public API revision still maps them: deleting a user through its models raised UndefinedColumn during the deploy window or after an application rollback. Keep both columns as retired, nullable, unused fields and regenerate fbb7488ebe8a (same revision ID) so the upgrade drops nothing. Verified against disposable Postgres: master's delete_transition_user now deletes a user who owns Stage 12A rows, and fails with the previous generated revision. A lifecycle test now requires every parent-revision column to survive and no nullable column to become required. The Stage 12A doc records the exact rollback rules and the follow-up revision that drops the retired columns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An import whose policy client_ref already existed fell back to a lookup that ignored country, and the report import let that in-request map override its country-filtered lookup, so a US report could link to the same user's UK saved policy. Imports now resolve existing client_refs only in the import country, skip a client_ref the user holds in another country with the new client_ref_conflict reason, and resolve report policy links with one country-filtered lookup that also sees policies inserted earlier in the same transaction. The four association services now share the insert-or-reuse import, the client_ref reads, the single-create clash check, and the v1 reference gate, replacing four copies of each and their per-resource import integrity errors. The dead, owner-unscoped read_complete_user_policy, read_user_policy_row, read_user_household_row, and household read_user helpers are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only POST /v2/me/imports had a body limit, so any signed-in user could persist multi-megabyte rows through single creates and patches. Every /v2/me* and /v2/user-* write now rejects a body over 256 KiB with 413 (imports keep 1 MiB) through one shared guard that runs after authentication. Caller inputs share field bounds on both paths: description and source_note at 10,000 characters, and the report validation snapshot at 500 calibration matches, 200 scorecard programs, and bounded strings. Persistence models stay unbounded so the Stage 10 mirror projection is unaffected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UserReportItem published provisions and validation, and UserPolicyItem published provenance, as untyped JSON, so the OpenAPI lost the shared contract's shapes. Responses now use the same ReportProvision, ReportValidation, and PolicyProvenance models that validate requests. Nullable contract keys a client omitted come back as null, and the optional interval parameter is omitted rather than null, as the contract specifies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Request logging only treated policies, households, and their user routes as native Supabase resources, so /v2/user-simulations, /v2/user-reports, and /v2/me* were logged with the configured v1 database sources of the simulation, report, and user route groups. One segment set now covers every native v2 Supabase resource, with reads and writes labeled by method. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the Auth0 client - Every user-route service factory built the v1 Cloud SQL session factory up front, so a v1 misconfiguration failed reads, lists, and /v2/me too. The default checker now builds the v1 reference service only when a request actually checks a v1 reference. - user_resource_operation turned unexpected exceptions into 500s without any log. It now logs the error type and the innermost frame locations, never the message, which can carry SQL parameters or token claims. - Each account deletion built a new Auth0 Management client and fetched a new token. Deleters now share one client per settings, so the token is reused until shortly before it expires. - The user-route log fields were passed as plain logging extras, which the structured log sink drops; they now go through policyengine_attributes so the user UUID and error type reach the logs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The staging live test deletes its own account through DELETE /v2/me, so Management API settings in staging would also delete the CI user from Auth0, and only documentation prevented it. The staging deploy job no longer receives AUTH0_MANAGEMENT_*, and the deploy validation rejects them for any environment other than production. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The no-token test listed 11 of the 25 /v2/me* and /v2/user-* operations by hand. It now enumerates every operation from the built application's OpenAPI, so new user routes are covered automatically. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Alembic tests run logging.config.fileConfig, which disables loggers that already exist, so the new failure-logging test passed alone but failed in the full suite. It now re-enables and records on the module logger directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Python's JSON parser and Pydantic's float and JsonValue types accept NaN and Infinity, but Postgres JSONB rejects them, so one such value in a report became a 503 and failed a whole import, including its valid items. Every user-route input base now sets allow_inf_nan=False, and report provision values use a finite-only recursive JSON value type. A single write containing one returns 422 and an import item containing one is skipped as invalid. Tests cover the types, the route, SQLite imports, and Postgres JSONB storage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… shapes user_resource_operation mapped every SQLAlchemyError to 503 without a log line, hiding schema drift such as the original UndefinedColumn and rejected data. It now logs the error type and frame locations, never the message, as it does for unexpected 500s. Responses reused the strict input models for report provisions and validation and for policy provenance, so any later tightening of the input bounds would make existing rows fail to serialize and break a whole list or /v2/me/export. Responses now use read shapes that mirror the contract without the input bounds and ignore unknown keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cted token - DELETE /v2/me now reads the Management client secret and obtains a token before deleting any PolicyEngine data. A configuration, Secret Manager, or credential failure returns 503 with nothing deleted, instead of a 502 after the data is gone. - A 401 from Auth0 drops the cached token and retries once with a fresh one, so a revoked or rotated token no longer fails every deletion for up to its 24-hour lifetime. - The Management settings are read only for account deletion, not for every /v2/me request. - The Stage 12A doc lists the runtime service account's Secret Manager grant as a prerequisite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ck accurately User-simulation, user-report, user-household, and /v2/me requests only store and read saved work, but logged the simulation flow and compute selection of their route groups (economy, report, household, and old_gateway). The migration context now takes include_compute, and request logging turns it off for the signed-in user resources while compute routes in the same groups keep their labels. The Stage 12A doc said the size check runs before body validation. FastAPI parses the whole body before any dependency, so the doc now says what the check bounds and what it does not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…users The previous Public API revision serves /v2/user-policies and /v2/user-households without authentication. The reviewer reproduced an anonymous PATCH and DELETE against the migrated schema, so the documented "rolling back the application is safe" would have handed signed-in users' saved work to anyone with a record ID. Add a ready-to-apply Cloud Armor proposal that denies /v2/me* and /v2/user-* with 403 at priority 900, with the ingress change that closes the *.run.app bypass, and rewrite the Stage 12A rollback guidance: roll forward when possible; once any signed-in user exists, apply the guard before shifting traffic back, accepting that saved work is unavailable while it holds; a database downgrade stays lossy. The doc also records that no other previous-revision route exposes Stage 12A data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hema The compatibility test only compared column sets and nullability. It now reflects the parent revision's schema, upgrades to head, and through the reflected parent tables inserts a row into every table the parent maps using only the parent's required columns, reads it back with every parent column, rewrites every non-key column, and deletes it. It also deletes, through the parent's users table, a user who owns rows in all four association tables and a user_identities row, and checks that the cascades run. It fails if head drops a parent column or adds a required column without a server default to a table the parent revision writes; only the two dormant association tables, which the parent never inserts into, may gain one. Run against the pre-fix revision that dropped country and label, the test fails. The Stage 12A doc now describes what the test checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
App v3 now calls GET /v2/me at the sign-in callback and offers to import the browser's signed-out reforms and reports only when that sign-in created the account. MeItem gains account_created, true only in the response to the request that inserted the users and user_identities rows. Identity resolution already returns that fact as ResolvedUser.provisioned, so it is threaded into AccountRead without another query. PATCH /v2/me and the export's user item follow the same rule. Concurrent first requests that lose the insert race and read the winner's rows back report false; a Postgres test confirms exactly one of eight concurrent first requests reports true. The Stage 12A doc now names app v3 as the only consumer, describes the sign-in check and the one-time import for new accounts, and notes that /v2/user-households and /v2/user-simulations have no app consumer yet. The contract registry and generated artifacts include the new field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n-in This reverts fd6b9b9. App v3 no longer limits the import to the sign-in that created the account: every sign-in moves the browser's signed-out v3 reforms and reports into the account, removes each item whose client_ref comes back in id_map, and keeps the rest for the next sign-in. Nothing reads account_created any more, so MeItem, PATCH /v2/me, the export's user item, the contract registry and the generated artifacts drop it, along with its tests. The Stage 12A doc's Consumers section now describes the import at every sign-in and the account page's GET /v2/me. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing has read provisioned since 7bd6814 dropped account_created from /v2/me, so ResolvedUser carries only the user ID and email again. The concurrency tests already check that concurrent first sign-ins create exactly one users row and one user_identities row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
partition_import_items validated each record on its own, so a request with two policies sharing a client_ref, one valid and one invalid, returned that client_ref in both id_map and skipped. App v3 removes every browser item whose client_ref comes back in id_map, so it deleted an item that was never stored. The import now counts client_refs across the whole request first. Every record whose client_ref appears more than once is skipped with the existing invalid reason and none of them is stored, so the contract keeps its reason values. The count spans policies, households, simulations and reports because id_map is keyed by client_ref alone; a policy and a report sharing one would otherwise put it in both lists too. App v3's reform (rf-) and report (lfr-) IDs never collide. Other items in the request are imported as before. Service tests cover a repeated valid and invalid pair, two valid items, the items stored beside a repeated client_ref (including a report whose policy link names it), and a client_ref shared across kinds. The route description and the Stage 12A doc's import section state the rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Cloud Armor proposal still assumed that app v3 reads /v2/me on every page load and imports once, at the first sign-in. App v3 now imports at every sign-in: it moves the browser's signed-out reforms and reports with POST /v2/me/imports, chunked per country at up to 500 items and about 950 KB, after one GET /v2/me with the sign-in country, and a failed import offers a manual "Try again". A sign-in with browser work therefore sends one unthrottled GET and usually one import per country with items, at most two for typical browsers. The import rule (priority 1150) rises from 20 to 60 requests per 600 s per IP: room for about 30 such sign-ins per IP every ten minutes behind shared NAT, retries included, while still bounding the heaviest write path. The rule shapes and expressions are unchanged. The Stage 12A doc's Consumers section now also lists the GET /v2/me sent before an import. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
user_identities is keyed by (issuer, subject), so changing PUBLIC_API_AUTH_ISSUER after users exist makes every one of them look new: the next sign-in provisions an empty account and orphans the old one and its saved work. The Stage 12A doc's Settings section now says so, and the Auth0 prerequisites and production rollout steps require the custom domain to be in place before the issuer is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
App v3 kept signed-out work in browser storage and copied it into the
account at sign-in through POST /v2/me/imports. All saved work now lives
in the database instead, signed in or not.
users gains user_type: "standard" (Auth0-backed; every existing row,
through a 'standard' server default) or "unauthenticated" (a signed-out
browser, whose users.id is the random UUID it keeps; every other user
column stays NULL). primary_country becomes nullable, and
ck_users_user_type and ck_users_standard_primary_country keep it
required for standard users. The unmerged revision fbb7488ebe8a is
regenerated from the updated models under the same ID with no
corrections, and stays expand-only for the previous revision.
The router-level authenticator now returns an AccountPrincipal: a
UserPrincipal for a bearer token, validated as before and taking
precedence, or else an UnauthenticatedPrincipal for a canonical UUID in
the PolicyEngine-Unauthenticated-User header, which needs no Auth0
configuration. No credential, or a malformed header, still gets 401
before any validation.
Provisioning is a strategy selected from the principal type.
StandardAccountProvisioning is the existing identity logic, unchanged.
UnauthenticatedAccountProvisioning inserts the user ON CONFLICT DO
NOTHING and re-reads it FOR SHARE, only on writes: reads by an absent
unauthenticated user find no records (empty lists, 404s, an export with
a null user). A UUID naming a standard user gets 401, so an
unauthenticated principal never acts on a standard user. PATCH /v2/me
is 403 for it, and DELETE /v2/me deletes only its data, without Auth0.
POST /v2/me/upgrade takes both credentials. Under a FOR UPDATE lock on
the unauthenticated user, it converts that user in place when the
identity has no user yet, merges every association into the identity's
existing user (IDs unchanged) and deletes it otherwise, and touches
nothing when no unauthenticated user has that ID. The response is
{"item": MeItem, "outcome": "converted" | "merged" | "none"}.
The import route and everything only it used are removed, including
client_ref on every association model, input, response, and table, and
its per-user unique constraints. An unauthenticated user's ID is a
credential, so it is never logged: its log records carry only
user_type, and the upgrade logs only its outcome.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…guard Every /v2/user-* operation, and GET /v2/me, GET /v2/me/export and DELETE /v2/me, is now recorded once with Authorization and once with the PolicyEngine-Unauthenticated-User header. PATCH /v2/me stays signed-in only, POST /v2/me/upgrade requires both headers, and the POST /v2/me/imports contract and every client_ref response field are gone. GET /v2/me also anchors result.item.user_type. The migration-contracts guard now requires each user-owned contract to carry Authorization or the unauthenticated-user header, and keys duplicate detection by method, path and headers so one route may be recorded per credential. The generated contract JSON and Markdown are regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…checks The staging live test no longer imports or sends client_refs. A new check saves work as fresh random browser UUIDs: reads before the first write find nothing, a signed-out write reads back, PATCH /v2/me is 403, the signed-in user cannot see another browser's work, one browser is converted into the test user's account and a second merged into it, and a signed-out DELETE /v2/me removes only its own work. It starts and ends by deleting the test user's account, so it can run repeatedly. The candidate smoke test also checks that a malformed PolicyEngine-Unauthenticated-User header gets 401. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Stage 12A doc now describes the design that replaced browser imports: app v3 sends PolicyEngine-Unauthenticated-User while signed out and calls POST /v2/me/upgrade at sign-in; the standard and unauthenticated user types; the two provisioning strategies and read-without-provisioning; conversion, merge and their concurrency; the security notes (the UUID is a bearer credential kept out of URLs and logs, an unauthenticated principal never acts on a standard user, and only edge throttling bounds anonymous user creation); the regenerated revision; and why a rollback now needs the guard from the first user-owned record, signed in or not. The import sections are removed. The proposed Cloud Armor user-writes policy drops the import throttle (priority 1150); its per-IP write throttle covers signed-out writes and POST /v2/me/upgrade and is the only bound on minting unauthenticated users. The rollback guard still denies every /v2/me* and /v2/user-* path. The testing and API-route skills and the changelog fragment follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-login-associations # Conflicts: # tests/unit/v2/test_data_crud_boundaries.py
Master's rules registry flags checks no rule describes. Add the sign-in and account-deletion libraries to the v2 mypy rule, and register the user-service SQL guard as api.v2.user-services-no-sql with proof fixtures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app holds the budget window asked for with a run only in memory, so a reload or another device lost it. Add budget_window_years to user_report_associations (NULL for none, otherwise 2 to the economy route's 75-year limit, checked by ck_user_report_associations_budget_window_years), accept it on create and PATCH, and return it. The single Stage 12A revision is regenerated with the same ID. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
App v3 can now compare a reform with a saved reform instead of current law, and a provision can grow with an index from one year on. Add baseline_user_policy_id to user_report_associations (the same owner's saved reform the report was compared with, ON DELETE SET NULL, checked like user_policy_id; its name is that reform's, so no label is stored), and accept the optional growth, growth_start, growth_base, growth_round_to and growth_round settings on report provisions. The single Stage 12A revision is regenerated with the same ID. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-login-associations # Conflicts: # docs/generated/rules.md
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3892
Summary
Stage 12A of the unified API migration, Public API side. It adds user identity and server-side user associations anchored to v1 artifacts, so app v3 saves every user's work through the Public API: signed in with Auth0 social sign-on, or signed out as the browser's unauthenticated user.
users.user_typeisstandard(signed in with Auth0) orunauthenticated(a browser's signed-out user, whose ID is a random UUID the browser made and every other user column null).require_user, before any 422):Authorization: Bearer …: Auth0 RS256 validation (libs/user_auth.py); 403 for machine clients, 503 when auth is unconfigured. A bad token is a 401 and never falls back to the header.PolicyEngine-Unauthenticated-User: <uuid>(lowercase, hyphenated): an unauthenticated principal. No Auth0 settings are needed. A UUID that names a standard user is a 401, so a signed-out browser can never act on an account.WWW-Authenticate: Bearer.StandardAccountProvisioning(theuser_identities(issuer, subject)→users.idmapping, race-safe, provisioning on any request) andUnauthenticatedAccountProvisioning(ausersrow with the browser's UUID, created only by a write: reads by a browser that has saved nothing return empty lists or 404 and create nothing), chosen by principal type.POST /v2/me/upgrade?country_id=takes both the bearer and the header. A first sign-in converts the unauthenticated user in place (adds the identity, setsstandardand the country), so every saved row stays; an existing account merges its rows in and the unauthenticated user is deleted; otherwise nothing happens (none). It never touches a standard user named in the header, and is safe under concurrent requests. Returns{item: MeItem, outcome}./v2/me:GET(MeItemnow hasuser_type, andprimary_countrymay be null),PATCH(403 for unauthenticated users),GET /v2/me/export(UK GDPR;useris null for a browser that has saved nothing) andDELETE /v2/me(an unauthenticated user's data is deleted without any Auth0 call). There is no import route: signed-out work is already in the database./v2/me*and/v2/user-*write rejects a body over 256 KiB with 413, checked after authentication (FastAPI has already parsed the body by then). NaN and Infinity are rejected with 422.descriptionandsource_noteare capped at 10,000 characters, and the report validation snapshot has list and string bounds./v2/user-policiesand/v2/user-householdsare locked down. The owner comes from the token or the unauthenticated header,user_idis removed from bodies and queries, and another user's row returns 404./v2/user-simulationsand/v2/user-reports.legacy_*_id), each checked to exist in Cloud SQL through the v1 SQLAlchemy models. A missing one returns 422.fbb7488ebe8a(parent91af4fc8d8c1):user_identities;usersgainsuser_type(defaultstandard, with a CHECK) andprimary_countrybecomes nullable, required for standard users byck_users_standard_primary_country;user_simulation_associationsanduser_report_associationsare reshaped, dropping their FKs tosimulations/reports. Their oldcountryandlabelcolumns are kept, nullable and unused, so the previous API revision can still load rows; a later revision drops them;user_policy_id(the saved reform the report was run from),baseline_user_policy_id(the saved reform it was compared with, when not current law; both are the same owner's rows, ON DELETE SET NULL, and the baseline's name is that reform's own, so no label is stored),source_note,provisions(each may carry optionalgrowth,growth_start,growth_base,growth_round_toandgrowth_roundsettings),validation, andbudget_window_years(the years of the budget window run with the report, from its year; NULL for none, otherwise 2 to the economy route's 75-year limit, checked byck_user_report_associations_budget_window_years).policyengine_api.data.v2.user_association_migration_qualification. It is wired intoseed-v2-database.ymlbefore the upgrade and fails if the dormant tables hold rows.master): the sign-in and account-deletion libraries join the v2 mypy rule, and the user-service SQL guard is registered asapi.v2.user-services-no-sqlwith proof fixtures.docs/migration/armor/proposed/, which also bounds how fast anyone can create unauthenticated users. An operator applies it.user_type./v2/user-householdsand/v2/user-simulationshave no app consumer yet.docs/migration/stage-12a-user-login.md.Remaining: none before merge. The Auth0 custom domain
auth.policyengine.orgis verified and serves issuerhttps://auth.policyengine.org/, which matches staging'sPUBLIC_API_AUTH_ISSUER.Done (2026-10-15):
staging:PUBLIC_API_AUTH_REQUIRED=1,PUBLIC_API_AUTH_ISSUER=https://auth.policyengine.org/(final: identities are keyed by(issuer, subject), so it must not change after the first sign-in),PUBLIC_API_AUTH_AUDIENCE=https://api.policyengine.org/.production:PUBLIC_API_AUTH_REQUIRED=0. Signed-out saving works without Auth0 settings; the production issuer and audience are set at rollout, once the custom domain is verified and staging has passed.https://api.policyengine.org/, RS256), with offline access now on. A staging-only CI application ("PolicyEngine API staging CI", password-realm grant) and its test user live in a dedicatedpolicyengine-staging-cidatabase connection with signup disabled, enabled only for that application. A password grant through the tenant domain returned a token for this audience.stagingenvironment. VariablesSTAGING_AUTH0_TEST_DOMAIN=auth.policyengine.org,STAGING_AUTH0_TEST_AUDIENCE=https://api.policyengine.org/andSTAGING_AUTH0_TEST_REALM=policyengine-staging-ci; secretsSTAGING_AUTH0_TEST_CLIENT_ID,STAGING_AUTH0_TEST_CLIENT_SECRET,STAGING_AUTH0_TEST_USERNAMEandSTAGING_AUTH0_TEST_PASSWORD.delete:userson the Management API. Its secret is version 1 ofpolicyengine-api-auth0-management-client-secretin Secret Manager, readable by the production runtime accountpolicyengine-api-cr-runtimeonly.productionhasAUTH0_MANAGEMENT_DOMAIN=policyengine.uk.auth0.com,AUTH0_MANAGEMENT_CLIENT_IDand the secretAUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE.DELETE /v2/mereads the secret and gets the Management token before deleting anything, so a credential failure returns 503 with nothing deleted. A 401 from Auth0 refreshes the token and retries once.kvvwlyzqrfpvrxinmlwt):user_simulation_associationsanduser_report_associationsempty,user_policies3,user_household_associations0;outcome: ok.kvrifaviwhzjztcbrfpy): all four tables empty;outcome: ok.Behaviour changes
/v2/user-policiesand/v2/user-householdspreviously accepted a caller-supplieduser_idwith no ownership check. Nothing in production calls them; app-v2 never did.PATCH /v2/user-householdsno longer acceptshousehold_id. Repointing goes throughlegacy_household_id.legacy_user_policy_mappingsentry) return 409 on PATCH and DELETE.DELETE /v2/mereturns 502 if the data was deleted but the Auth0 deletion then failed. A retry is safe.MeItem.emailstays null unless an Auth0 Action addsemail/email_verifiedto access tokens.Rollback
Roll forward whenever possible. The schema still serves the previous revision, but that revision serves
/v2/user-policiesand/v2/user-householdswithout authentication. Rolling the application back would let anyone list, read, rename, delete or create users' saved items.Before any user-owned record exists, an application rollback is safe and needs no database downgrade. Signed-out saving needs no configuration, so treat user-owned records as existing from the moment this revision serves traffic in an environment.
After that, apply the rollback guard first:
docs/migration/armor/proposed/20261013-pol-api-lb-stage-12a-rollback-guard.yaml, a Cloud Armor rule that denies/v2/me*and/v2/user-*with 403.internal-and-cloud-load-balancing, because*.run.appURLs bypass Cloud Armor.Saved work is unavailable while the guard holds. Signed-out flows and v1 routes are unaffected.
No other route in the previous revision exposes Stage 12A data.
A database downgrade is lossy. It fails while legacy-only rows or any simulation or report associations exist.
Testing
masterat1d987fb8and adding the baseline link and growth settings: themake testsuite (tests/to_refactor,tests/unit,tests/contractand the budget-window dedupe test) has 2738 passed.mypy,ruffand the quality guards (migration contracts, rules registry, CI scripts) pass.budget_window_years: the v2 Alembic lifecycle tests, thentest_v2_user_identity_persistence.pyat head (21 passed together, including a test that the CHECK rejects windows of 1 and 76 years). The persistence tests cover concurrent first signed-out writes making one user, concurrent upgrades converting once, two browsers upgrading at once (one converts, one merges), a merge moving all four association kinds with links intact, signed-out writes racing a conversion, and a standard user named in the header never being locked. The policy and household persistence tests passed earlier (11).test_v2_user_identity_persistence.py: 8 concurrent first sign-ins create one user, account deletion cascades, and qualification is read-only;/v2/me*and/v2/user-*operations, read from the built app's OpenAPI.alembic -c alembic-v2.ini checkis clean; a fresh upgrade, then downgrade -1 and re-upgrade, all succeed. The revision was regenerated with autogenerate under the same ID.ruff format --check,ruff checkandmypyare clean.openspec amendments (local-only specs, not in this PR)
v2-user-policy-associations:user_idis removed;legacy_policy_id, a nullablepolicy_idwith a CHECK,baseline,provenance;legacy_policy_id.v2-user-household-associations: legacy references, owner scoping, andhousehold_idis no longer patchable.user_identitiesprovisioning;/v2/mewith export and deletion order;Authorizationor the unauthenticated header on user-owned routes.Related
🤖 Generated with Claude Code