Skip to content

Stage 12A: Sign-in identity and v1-anchored user associations - #3891

Merged
anth-volk merged 45 commits into
masterfrom
feat/stage-12a-user-login-associations
Oct 15, 2026
Merged

anth-volk merged 45 commits into
masterfrom
feat/stage-12a-user-login-associations

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

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.

  • Two user types: users.user_type is standard (signed in with Auth0) or unauthenticated (a browser's signed-out user, whose ID is a random UUID the browser made and every other user column null).
  • Who a request is made as (router-level 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.
    • Otherwise 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.
    • Neither: 401 with WWW-Authenticate: Bearer.
  • Provisioning strategies: StandardAccountProvisioning (the user_identities (issuer, subject) → users.id mapping, race-safe, provisioning on any request) and UnauthenticatedAccountProvisioning (a users row 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.
  • Upgrade at sign-in: 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, sets standard and 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 (MeItem now has user_type, and primary_country may be null), PATCH (403 for unauthenticated users), GET /v2/me/export (UK GDPR; user is null for a browser that has saved nothing) and DELETE /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.
  • Write limits: every /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. description and source_note are capped at 10,000 characters, and the report validation snapshot has list and string bounds.
  • Associations:
    • /v2/user-policies and /v2/user-households are locked down. The owner comes from the token or the unauthenticated header, user_id is removed from bodies and queries, and another user's row returns 404.
    • New /v2/user-simulations and /v2/user-reports.
    • All four accept v1 references (legacy_*_id), each checked to exist in Cloud SQL through the v1 SQLAlchemy models. A missing one returns 422.
  • Shared reports: a saved report's ID is its shareable link. GET /v2/shared-reports/{id}?country_id= reads any user's saved report with no credential, outside the authenticated router, and never provisions a user (404 when no report has that ID in that country).
    • It leaves out user_id, user_policy_id and baseline_user_policy_id, and names the reform the report was compared with instead (baseline_reform: {name}, null for current law or once that reform is deleted).
    • The owner routes stay owner-scoped, and there is no shared list or search.
  • Schema: one autogenerated v2 revision, fbb7488ebe8a (parent 91af4fc8d8c1):
    • creates user_identities;
    • users gains user_type (default standard, with a CHECK) and primary_country becomes nullable, required for standard users by ck_users_standard_primary_country;
    • every association gets nullable v1 and v2 references with a CHECK;
    • the dormant user_simulation_associations and user_report_associations are reshaped, dropping their FKs to simulations / reports. Their old country and label columns are kept, nullable and unused, so the previous API revision can still load rows; a later revision drops them;
    • v3 report fields are added: 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 optional growth, growth_start, growth_base, growth_round_to and growth_round settings), validation, and budget_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 by ck_user_report_associations_budget_window_years).
  • Persistence: every SQL access goes through SQLAlchemy or SQLModel, and every schema change through Alembic autogeneration. There are no v1 schema changes.
  • Qualification: a read-only SQLAlchemy command, policyengine_api.data.v2.user_association_migration_qualification. It is wired into seed-v2-database.yml before the upgrade and fails if the dormant tables hold rows.
  • Rules registry (from master): the sign-in and account-deletion libraries join the v2 mypy rule, and the user-service SQL guard is registered as api.v2.user-services-no-sql with proof fixtures.
  • Ruff and CI scripts (from master): this branch's code passes ruff groups 1 and 2, and its 19 model bases are listed in runtime-evaluated-base-classes, so their subclasses keep the imports Pydantic needs at runtime. The staging release-test step now runs .github/scripts/run_release_tests.sh, which also unsets the PUBLIC_API_AUTH_* settings, so the staging job's make test never sees them.
  • Bundled versions: PolicyEngine bundle 6.4.1, matching the simulation API (the update from Update PolicyEngine bundle to 6.4.1 #3939, which closed unmerged): policyengine 6.4.1, policyengine-core 3.33.0, policyengine-us 2.2.1, policyengine-uk 2.130.1 (was 2.126.12), spm-calculator 1.0.0. Certified data: US populace-us-2024-spm-20260915, UK policyengine-uk-data-1.58.1 (was 1.58.0). UK 2.130.1 adds variables and parameters, so the installed-catalog test expects the new counts.
  • Cloud Armor: a proposed per-IP rate limit for user writes, in preview mode under docs/migration/armor/proposed/, which also bounds how fast anyone can create unauthenticated users. An operator applies it.
  • Logging: an unauthenticated user's ID is the browser's credential, so it is never logged; signed-out events log only user_type.
  • Observability: saved-work requests are logged as Supabase traffic with no compute labels. Unexpected 500s and persistence 503s are logged with the error type and frame locations, never the message. Responses use lenient read shapes, so any stored row serializes.
  • Consumers: app v3 is the only consumer (Stage 12A: Sign in to app v3 and save work to the account policyengine-app-v2#1252): it sends the unauthenticated header when signed out, calls the upgrade at sign-in, and opens report links through /v2/shared-reports. The legacy builder stays browser-only. /v2/user-households and /v2/user-simulations have no app consumer yet.
  • Docs: docs/migration/stage-12a-user-login.md.

⚠️ Merge prerequisites

Remaining: none before merge. The Auth0 custom domain auth.policyengine.org is verified and serves issuer https://auth.policyengine.org/, which matches staging's PUBLIC_API_AUTH_ISSUER.

Done (2026-10-15):

  1. GitHub variables.
    • 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, PUBLIC_API_AUTH_ISSUER=https://auth.policyengine.org/ and PUBLIC_API_AUTH_AUDIENCE=https://api.policyengine.org/ (set 15 October; master doesn't read them, so they take effect with this PR's first production deploy, which runs only after the staging candidate passes the live Auth0 test). Signed-out saving works without Auth0 settings.
  2. Auth0. The audience is the existing "PolicyEngine API" (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 dedicated policyengine-staging-ci database connection with signup disabled, enabled only for that application. A password grant through the tenant domain returned a token for this audience.
  3. Live staging test, GitHub staging environment. Variables STAGING_AUTH0_TEST_DOMAIN=auth.policyengine.org, STAGING_AUTH0_TEST_AUDIENCE=https://api.policyengine.org/ and STAGING_AUTH0_TEST_REALM=policyengine-staging-ci; secrets STAGING_AUTH0_TEST_CLIENT_ID, STAGING_AUTH0_TEST_CLIENT_SECRET, STAGING_AUTH0_TEST_USERNAME and STAGING_AUTH0_TEST_PASSWORD.
  4. Account deletion, production only. "PolicyEngine API account deletion (production)" has only delete:users on the Management API. Its secret is version 1 of policyengine-api-auth0-management-client-secret in Secret Manager, readable by the production runtime account policyengine-api-cr-runtime only. production has AUTH0_MANAGEMENT_DOMAIN=policyengine.uk.auth0.com, AUTH0_MANAGEMENT_CLIENT_ID and the secret AUTH0_MANAGEMENT_CLIENT_SECRET_RESOURCE.
    • DELETE /v2/me reads 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.
    • These settings are production-only. The staging job doesn't pass them, and deploy validation fails if they are set outside production, because the staging live test deletes its own account and would otherwise delete the CI user from Auth0.
  5. Qualification, run read-only as the migration role:
    • staging (kvvwlyzqrfpvrxinmlwt): user_simulation_associations and user_report_associations empty, user_policies 3, user_household_associations 0; outcome: ok.
    • production (kvrifaviwhzjztcbrfpy): all four tables empty; outcome: ok.
    • The seed workflow runs the same check before every upgrade.

Behaviour changes

  • The two existing user routes now require authentication. /v2/user-policies and /v2/user-households previously accepted a caller-supplied user_id with no ownership check. Nothing in production calls them; app-v2 never did.
  • PATCH /v2/user-households no longer accepts household_id. Repointing goes through legacy_household_id.
  • Rows owned by the Stage 10 mirror (those with a legacy_user_policy_mappings entry) return 409 on PATCH and DELETE.
  • Saved reports can be read by anyone holding their ID (/v2/shared-reports): their name, source note, provisions, budget window, validation pin and compared reform's name, never their owner.
  • Status codes:
    • Validation errors return 422, matching existing v2 behaviour.
    • A body that isn't valid JSON gets 422 before authentication.
    • DELETE /v2/me returns 502 if the data was deleted but the Auth0 deletion then failed. A retry is safe.
  • MeItem.email stays null unless an Auth0 Action adds email / email_verified to access tokens.

Rollback

Roll forward whenever possible. The schema still serves the previous revision, but that revision serves /v2/user-policies and /v2/user-households without 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:

    1. Apply 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.
    2. Set the service's ingress to internal-and-cloud-load-balancing, because *.run.app URLs bypass Cloud Armor.
    3. Shift traffic back.

    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

  • After merging master at e76065e8 (ruff groups 1 and 2, and the push.yml steps moved into scripts) and updating the bundle to 6.4.1: the make test suite (tests/to_refactor, tests/unit, tests/contract and the budget-window dedupe test) has 2769 passed, both before and after the bundle update. ruff format --check, ruff check with master's rule families, mypy (167 files), the quality guards (migration contracts, rules registry, CI scripts) and the rules tool's tests (74) pass. The migration contract docs are unchanged by the exporter.
  • The cross-database integration job, run locally on 6.4.1 against disposable Postgres 17, MySQL 8.4 and Redis 7.2: all seven test steps pass (83 tests). This includes the installed-catalog test that failed Update PolicyEngine bundle to 6.4.1 #3939, the production-scale catalog publication qualification, and the identity persistence tests. alembic -c alembic-v2.ini check is clean at head fbb7488ebe8a.
  • Against a disposable Postgres 16 after adding budget_window_years: the v2 Alembic lifecycle tests, then test_v2_user_identity_persistence.py at 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).
  • Cross-database integration against disposable Postgres 17 and MySQL 8.4: 74 passed on the first version. After the review fixes, the v2 Alembic lifecycle tests (38 passed) and the policy, household and identity persistence tests (51 passed) were rerun. This covers:
    • the v1 and v2 Alembic lifecycles, including a test that dormant rows make the upgrade fail;
    • the Stage 10/11 persistence and mirror tests;
    • the new test_v2_user_identity_persistence.py: 8 concurrent first sign-ins create one user, account deletion cascades, and qualification is read-only;
    • a test that reflects the previous revision's schema, upgrades to head, and runs insert, read, update and delete through the previous revision's tables on all 38 tables it maps, plus a user deletion cascade.
  • A 401 test covers all 25 /v2/me* and /v2/user-* operations, read from the built app's OpenAPI.
  • After adding /v2/shared-reports: tests/unit/v2, tests/contract, and the migration artifact, flag, logging and ASGI tests have 971 passed. New tests cover the credential-free read with the private fields left out, the owner read still needing a credential, 404, query and ID validation, its route group, its OpenAPI operation (no security), and the service reading another user's report with its compared reform's name (null once that reform is deleted). mypy, ruff and the quality guards pass; the migration contract docs are regenerated.
  • alembic -c alembic-v2.ini check is 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 check and mypy are clean.
  • The live staging test (now also covering a signed-out write, a conversion and a merge) has not been run; it needs this PR deployed to staging and the custom domain verified.

openspec amendments (local-only specs, not in this PR)

  • v2-user-policy-associations:
    • the owner comes from the token, and user_id is removed;
    • 401 / 403 / 404 / 503 semantics;
    • legacy_policy_id, a nullable policy_id with a CHECK, baseline, provenance;
    • repointing, and 409 on rows owned by the Stage 10 mirror;
    • the Stage 10 projection sets legacy_policy_id.
  • v2-user-household-associations: legacy references, owner scoping, and household_id is no longer patchable.
  • New capabilities:
    • user_identities provisioning;
    • v1-anchored simulation and report associations;
    • user types, the unauthenticated principal and the upgrade;
    • /v2/me with export and deletion order;
    • the contract guard that requires Authorization or the unauthenticated header on user-owned routes.

Related

🤖 Generated with Claude Code

anth-volk and others added 10 commits October 12, 2026 23:27
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>
@codecov

codecov Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.41414% with 361 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.47%. Comparing base (e76065e) to head (3575c62).

Files with missing lines Patch % Lines
policyengine_api/fastapi_routes/dependencies.py 42.62% 19 Missing and 16 partials ⚠️
policyengine_api/libs/auth0_management.py 86.92% 11 Missing and 6 partials ⚠️
...icyengine_api/services/v2/user_reports/services.py 77.14% 13 Missing and 3 partials ⚠️
policyengine_api/libs/user_auth.py 90.00% 7 Missing and 6 partials ⚠️
policyengine_api/services/v2/user_reports/types.py 93.16% 5 Missing and 6 partials ⚠️
...ngine_api/services/v2/user_simulations/services.py 80.00% 10 Missing and 1 partial ⚠️
...ata/v2/user_association_migration_qualification.py 89.01% 6 Missing and 4 partials ⚠️
...ngine_api/fastapi_routes/v2/user_reports/routes.py 87.32% 7 Missing and 2 partials ⚠️
...licyengine_api/fastapi_routes/v2/user_resources.py 86.76% 7 Missing and 2 partials ⚠️
...e_api/fastapi_routes/v2/user_simulations/routes.py 84.74% 7 Missing and 2 partials ⚠️
... and 56 more
Additional details and impacted files
@@             Coverage Diff             @@
##           master    #3891       +/-   ##
===========================================
+ Coverage   43.80%   85.47%   +41.66%     
===========================================
  Files         183      255       +72     
  Lines       11219    14639     +3420     
  Branches     1993     2578      +585     
===========================================
+ Hits         4915    12512     +7597     
+ Misses       5812     1320     -4492     
- Partials      492      807      +315     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

anth-volk and others added 9 commits October 13, 2026 15:51
… 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>
anth-volk and others added 3 commits October 13, 2026 17:02
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>
anth-volk added a commit that referenced this pull request Oct 20, 2026
#3891's previous-revision test picks a value for each required column
through _test_value, whose type checks exceeded the complexity and
return limits (C901, PLR0911). The type checks move to TYPED_TEST_VALUES,
an ordered table that keeps Enum ahead of its String base, so every
column gets the same value as before; the lifecycle tests pass against a
disposable PostgreSQL 17 database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
#3891's live signed-in lifecycle and signed-out upgrade tests exceeded
the statement limit (PLR0915). Each saved resource's round trip runs in
_assert_saved_resource_lifecycle, which takes the resource, its v1
field and id as one _SavedResource; the linked reform, report and export
checks run in _assert_export_links_the_saved_reform; and the signed-out
saving checks run in _assert_signed_out_saving. The requests, their
bodies and order, and the assertions are unchanged. (The stack's split
of the earlier live policy lifecycle test no longer applies: #3891
replaced that test.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
#3891's test that signed-in user resources log Supabase sources took
five parametrized arguments besides monkeypatch (PLR0913). Each case is
now a _UserResourceRequest, and the test IDs are rebuilt to match the
ones pytest generated for the five separate parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The /v2/me, user-report, user-simulation, user-household and user-policy
routes that #3891 added or reshaped took 44 dependencies as parameter
defaults. Their query dependencies become module-level Annotated
aliases, as the other v2 routes do, and the request-size check and the
upgrade route's unauthenticated-user header become Annotated
parameters.

Each handler's principal comes from require_user, the authenticator
that NativeRouteDependencies injects per router. FastAPI resolves string
annotations against module globals only: with `from __future__ import
annotations`, Annotated[AccountPrincipal, Depends(require_user)] would
silently become a required "principal" query parameter. These five
modules therefore no longer use the future import, so their annotations
are evaluated where the handlers are defined, and import the route
dependency types and their default service factory at run time. Moving
the default factory import out of _service_factory also clears its
PLC0415 finding in the three new modules, as the other v2 resource
routers already do; the dependencies module is already loaded wherever
these routers are built.

The OpenAPI documents, with the injected test authenticator and with the
default one, are byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
Of the 18 PLC0415 findings left in the code #3891 added, the route
dependencies' account-request authenticator import moves to the top
level. Its module is already loaded wherever the dependencies module is,
and it creates no cycle; the ASGI and Flask entry points import exactly
the same modules as before.

The other deferred imports keep an inline reason, worded as for the
existing ones: the v1 session factory is looked up per call so tests
can replace it; the v1 reference checks, v2 persistence, the
user-account service and Auth0 Management load only when a default
factory runs or a v1 reference is checked; readiness validation loads
the user-auth and Auth0 settings only when it runs; and the Google
Secret Manager client loads only when Auth0 Management reads a secret.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The user-service proof fixture's docstring puts the rule's verdict on
its own line, as the validator fixtures do. The import side-effect
test's subprocess script asserts the association qualification module
is unloaded across three lines, and the identity persistence test's
qualification test names the qualification module once, as
qualification, so its pending checker fits on one line. The checks are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
- FBT001: require_not_mirror_owned and require_owned_user_policy take
  their boolean flag by keyword, and their three callers pass it so.
- B008: the default account authenticator declares its bearer and
  unauthenticated-user security dependencies with Annotated instead of
  as parameter defaults.
- ARG001: DELETE /v2/me needs its country query only for validation, so
  the parameter is named _query, like the _size request-size check.

The OpenAPI documents, with the injected test authenticator and with
the default one, are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
- PT018: split the 12 composite assertions, so a failure names the part
  that failed.
- SIM117: the three pytest.raises blocks that open a transaction enter
  both contexts in one with statement, in the same order.
- PT011, PT007: the update-input validation tests match the message each
  ValueError really carries, and the user-service guard's parametrize
  values are a list.
- ARG: the qualification test and the signed-out header test request the
  sessions and configured_auth fixtures through pytest.mark.usefixtures;
  stand-in lambdas and result callbacks that are only called
  positionally prefix their unused parameters with an underscore, as
  does the Alembic test's unused loop name; and the deferred reference
  service test's stand-in keeps its keyword parameters with a reason.
- FBT001: the signed-out header test takes auth_configured by keyword,
  which pytest already does.
- SIM118, RUF005: drop a .keys() membership check and unpack a list
  instead of concatenating it.

The collected test IDs are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The Management API client compares the token response with HTTPStatus.OK
and retries a deletion after HTTPStatus.UNAUTHORIZED, as the gateway
token provider does. HTTPStatus is an IntEnum, so the comparisons are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
user_resource_operation maps each typed service failure #3891 added to
a status, message and headers, and logs persistence and unexpected
failures by event name only. The new characterization test pins all
eighteen cases, including subclasses that must match their base's
clause and an IntegrityError that must win over the SQLAlchemyError
clause, and passes on the current code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The user-report and user-simulation create routes call a module helper
that checks the body and query countries agree and runs the creation,
as the user-household and user-policy routes do. The /v2/me router
builds its signed-in-only dependency through _signed_in_user_dependency
and runs the upgrade through _upgrade_account. The three builders #3891
added stay within the complexity limit (C901); route registration,
order, signatures and the OpenAPI documents are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
#3891 added include_compute to get_migration_context, giving it six
arguments (PLR0913). Its db_entity and sim_flow overrides were never
passed: every caller, including the request logging and the tests,
lets the route group's configuration name them. The overrides go, so
the context takes the entity and flow from the route group and, with
include_compute=False, records no flow; every logged context is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
…value each

The PLR0913 findings in the code #3891 added:

- The user-household and user-policy services, their route Protocols and
  routes take an optional UserHouseholdFilter or UserPolicyFilter (the
  v2 and v1 ids to narrow a listing to) instead of two keyword
  arguments, as the metadata layers take a ParameterValueFilter. The
  routes build the filter from the same query fields.
- read_user_report_rows and read_user_simulation_rows take a
  UserReportListing or UserSimulationListing, as the household and
  policy row readers take their listings.
- create_user_identity takes the UserPrincipal whose issuer, subject and
  email it stores, instead of the three fields.

The statements, their parameters and the OpenAPI documents are
unchanged. The route and service tests, and the concurrent sign-in
test's stand-in, use the new arguments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
#3891's user-route credential check took the contract workflow guard's
_check_workflows past the complexity limit (C901). Each request's key
now comes from _request_key and its problems from _request_violations,
which runs the same checks in the same order, so the guard reports the
same violations in the same order; the pinned workflow-guard test and
the credential tests pass unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
#3891's previous-revision test picks a value for each required column
through _test_value, whose type checks exceeded the complexity and
return limits (C901, PLR0911). The type checks move to TYPED_TEST_VALUES,
an ordered table that keeps Enum ahead of its String base, so every
column gets the same value as before; the lifecycle tests pass against a
disposable PostgreSQL 17 database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
#3891's live signed-in lifecycle and signed-out upgrade tests exceeded
the statement limit (PLR0915). Each saved resource's round trip runs in
_assert_saved_resource_lifecycle, which takes the resource, its v1
field and id as one _SavedResource; the linked reform, report and export
checks run in _assert_export_links_the_saved_reform; and the signed-out
saving checks run in _assert_signed_out_saving. The requests, their
bodies and order, and the assertions are unchanged. (The stack's split
of the earlier live policy lifecycle test no longer applies: #3891
replaced that test.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
#3891's test that signed-in user resources log Supabase sources took
five parametrized arguments besides monkeypatch (PLR0913). Each case is
now a _UserResourceRequest, and the test IDs are rebuilt to match the
ones pytest generated for the five separate parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The /v2/me, user-report, user-simulation, user-household and user-policy
routes that #3891 added or reshaped took 44 dependencies as parameter
defaults. Their query dependencies become module-level Annotated
aliases, as the other v2 routes do, and the request-size check and the
upgrade route's unauthenticated-user header become Annotated
parameters.

Each handler's principal comes from require_user, the authenticator
that NativeRouteDependencies injects per router. FastAPI resolves string
annotations against module globals only: with `from __future__ import
annotations`, Annotated[AccountPrincipal, Depends(require_user)] would
silently become a required "principal" query parameter. These five
modules therefore no longer use the future import, so their annotations
are evaluated where the handlers are defined, and import the route
dependency types and their default service factory at run time. Moving
the default factory import out of _service_factory also clears its
PLC0415 finding in the three new modules, as the other v2 resource
routers already do; the dependencies module is already loaded wherever
these routers are built.

The OpenAPI documents, with the injected test authenticator and with the
default one, are byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
Of the 18 PLC0415 findings left in the code #3891 added, the route
dependencies' account-request authenticator import moves to the top
level. Its module is already loaded wherever the dependencies module is,
and it creates no cycle; the ASGI and Flask entry points import exactly
the same modules as before.

The other deferred imports keep an inline reason, worded as for the
existing ones: the v1 session factory is looked up per call so tests
can replace it; the v1 reference checks, v2 persistence, the
user-account service and Auth0 Management load only when a default
factory runs or a v1 reference is checked; readiness validation loads
the user-auth and Auth0 settings only when it runs; and the Google
Secret Manager client loads only when Auth0 Management reads a secret.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The user-service proof fixture's docstring puts the rule's verdict on
its own line, as the validator fixtures do. The import side-effect
test's subprocess script asserts the association qualification module
is unloaded across three lines, and the identity persistence test's
qualification test names the qualification module once, as
qualification, so its pending checker fits on one line. The checks are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
- FBT001: require_not_mirror_owned and require_owned_user_policy take
  their boolean flag by keyword, and their three callers pass it so.
- B008: the default account authenticator declares its bearer and
  unauthenticated-user security dependencies with Annotated instead of
  as parameter defaults.
- ARG001: DELETE /v2/me needs its country query only for validation, so
  the parameter is named _query, like the _size request-size check.

The OpenAPI documents, with the injected test authenticator and with
the default one, are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
- PT018: split the 12 composite assertions, so a failure names the part
  that failed.
- SIM117: the three pytest.raises blocks that open a transaction enter
  both contexts in one with statement, in the same order.
- PT011, PT007: the update-input validation tests match the message each
  ValueError really carries, and the user-service guard's parametrize
  values are a list.
- ARG: the qualification test and the signed-out header test request the
  sessions and configured_auth fixtures through pytest.mark.usefixtures;
  stand-in lambdas and result callbacks that are only called
  positionally prefix their unused parameters with an underscore, as
  does the Alembic test's unused loop name; and the deferred reference
  service test's stand-in keeps its keyword parameters with a reason.
- FBT001: the signed-out header test takes auth_configured by keyword,
  which pytest already does.
- SIM118, RUF005: drop a .keys() membership check and unpack a list
  instead of concatenating it.

The collected test IDs are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The Management API client compares the token response with HTTPStatus.OK
and retries a deletion after HTTPStatus.UNAUTHORIZED, as the gateway
token provider does. HTTPStatus is an IntEnum, so the comparisons are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
user_resource_operation maps each typed service failure #3891 added to
a status, message and headers, and logs persistence and unexpected
failures by event name only. The new characterization test pins all
eighteen cases, including subclasses that must match their base's
clause and an IntegrityError that must win over the SQLAlchemyError
clause, and passes on the current code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The user-report and user-simulation create routes call a module helper
that checks the body and query countries agree and runs the creation,
as the user-household and user-policy routes do. The /v2/me router
builds its signed-in-only dependency through _signed_in_user_dependency
and runs the upgrade through _upgrade_account. The three builders #3891
added stay within the complexity limit (C901); route registration,
order, signatures and the OpenAPI documents are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
#3891 added include_compute to get_migration_context, giving it six
arguments (PLR0913). Its db_entity and sim_flow overrides were never
passed: every caller, including the request logging and the tests,
lets the route group's configuration name them. The overrides go, so
the context takes the entity and flow from the route group and, with
include_compute=False, records no flow; every logged context is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
…value each

The PLR0913 findings in the code #3891 added:

- The user-household and user-policy services, their route Protocols and
  routes take an optional UserHouseholdFilter or UserPolicyFilter (the
  v2 and v1 ids to narrow a listing to) instead of two keyword
  arguments, as the metadata layers take a ParameterValueFilter. The
  routes build the filter from the same query fields.
- read_user_report_rows and read_user_simulation_rows take a
  UserReportListing or UserSimulationListing, as the household and
  policy row readers take their listings.
- create_user_identity takes the UserPrincipal whose issuer, subject and
  email it stores, instead of the three fields.

The statements, their parameters and the OpenAPI documents are
unchanged. The route and service tests, and the concurrent sign-in
test's stand-in, use the new arguments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The Management API client compares the token response with HTTPStatus.OK
and retries a deletion after HTTPStatus.UNAUTHORIZED, as the gateway
token provider does. HTTPStatus is an IntEnum, so the comparisons are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
user_resource_operation maps each typed service failure #3891 added to
a status, message and headers, and logs persistence and unexpected
failures by event name only. The new characterization test pins all
eighteen cases, including subclasses that must match their base's
clause and an IntegrityError that must win over the SQLAlchemyError
clause, and passes on the current code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 20, 2026
The user-report and user-simulation create routes call a module helper
that checks the body and query countries agree and runs the creation,
as the user-household and user-policy routes do. The /v2/me router
builds its signed-in-only dependency through _signed_in_user_dependency
and runs the upgrade through _upgrade_account. The three builders #3891
added stay within the complexity limit (C901); route registration,
order, signatures and the OpenAPI documents are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stage 12A: Sign-in identity and v1-anchored user associations

1 participant