Skip to content

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

Open
anth-volk wants to merge 45 commits into
masterfrom
feat/stage-12a-user-login-associations
Open

anth-volk wants to merge 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. 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.
  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 92.34098% with 177 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.15%. Comparing base (e93da19) to head (77dc183).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
policyengine_api/fastapi_routes/dependencies.py 43.33% 19 Missing and 15 partials ⚠️
policyengine_api/libs/auth0_management.py 88.28% 10 Missing and 5 partials ⚠️
policyengine_api/libs/user_auth.py 91.47% 6 Missing and 5 partials ⚠️
...ata/v2/user_association_migration_qualification.py 91.11% 5 Missing and 3 partials ⚠️
...icyengine_api/services/v2/user_reports/services.py 87.30% 6 Missing and 2 partials ⚠️
policyengine_api/services/v2/user_reports/types.py 94.40% 3 Missing and 5 partials ⚠️
...ngine_api/fastapi_routes/v2/user_reports/routes.py 89.47% 5 Missing and 1 partial ⚠️
...licyengine_api/fastapi_routes/v2/user_resources.py 91.04% 5 Missing and 1 partial ⚠️
...e_api/fastapi_routes/v2/user_simulations/routes.py 89.47% 5 Missing and 1 partial ⚠️
...es/v2/user_households/database_connectors/reads.py 58.33% 2 Missing and 3 partials ⚠️
... and 30 more
Additional details and impacted files
@@             Coverage Diff             @@
##           master    #3891       +/-   ##
===========================================
+ Coverage   45.25%   88.15%   +42.90%     
===========================================
  Files         183      255       +72     
  Lines       11085    14349     +3264     
  Branches     1907     2435      +528     
===========================================
+ Hits         5016    12649     +7633     
+ Misses       5647     1031     -4616     
- Partials      422      669      +247     

☔ 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>
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>
anth-volk and others added 11 commits October 13, 2026 23:32
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>
@anth-volk
anth-volk marked this pull request as ready for review October 14, 2026 21:29
@anth-volk
anth-volk requested a review from a team as a code owner October 14, 2026 21:29
@anth-volk
anth-volk removed the request for review from a team October 14, 2026 21:29
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
anth-volk and others added 5 commits October 15, 2026 19:51
App v3 report links carry the report's user-report ID, and anyone holding
the link can open the report. GET /v2/shared-reports/{id}?country_id= reads
any user's saved report with no credential and never provisions a user. 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). The
owner routes stay owner-scoped.

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

Brings in ruff groups 1 and 2 (safe auto-fixes and typing-only imports)
and the move of push.yml's multi-line steps into tested scripts.

Conflict resolution:
- .github/workflows/push.yml: the release-test step now calls
  .github/scripts/run_release_tests.sh, as on master. The script also
  unsets PUBLIC_API_AUTH_REQUIRED, PUBLIC_API_AUTH_ISSUER and
  PUBLIC_API_AUTH_AUDIENCE, which this branch had added to the inline
  env -u list. The script's subprocess test and the branch's workflow
  test now check the script.
- The 20 conflicted Python files (dependencies, the v2 router, the
  user_policies and user_households routes, response models, services,
  types, validators and connectors, and five tests/unit/v2 files) keep
  this branch's Stage 12A code with master's ruff fixes applied to it.
  Master's changes to these files were purely ruff output: running
  ruff check --fix --unsafe-fixes and ruff format on the merge base
  with master's configuration reproduces master's version of all 20.
- policyengine_api/data/v2/models/__init__.py: keeps master's sorted
  import order and imports UserIdentity and UserType with User.
- policyengine_api/data/v2/models/reports.py: drops the
  UserReportAssociation import that master had re-sorted; this branch
  removed the relationship that used it, and git kept master's moved
  copy of the line.
- pyproject.toml: lists this branch's 19 model base classes in
  runtime-evaluated-base-classes, so ruff keeps the imports their
  subclasses need at runtime (api.quality.runtime-evaluated-models-resolve).
- docs/generated/rules.md and the AGENTS.md rules block are regenerated
  with python -m scripts.rules render. The migration contract docs are
  unchanged by scripts/export_migration_contracts.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Master now enforces ruff check with the group 1 (safe auto-fixes) and
group 2 (typing-only imports) rule families. Fix everything they find
in the code this branch adds: ruff check --fix --unsafe-fixes, then
ruff format, as master's own adoption did.

- Imports used only in annotations move under TYPE_CHECKING. The model
  bases listed in runtime-evaluated-base-classes keep the imports
  Pydantic needs at runtime.
- user_resources.py keeps fastapi.Request at runtime with a noqa: FastAPI
  reads the request-size dependency's annotations when routes are
  registered, and no route decorator in that module tells ruff so.
- datetime.UTC, functools.cache and X | Y unions replace their older
  spellings, and import blocks are re-sorted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Match the simulation API, which serves 6.4.1. Reproduces the bundle
updater's change (closed PR #3939) on this branch: the pyproject.toml
and docker/Dockerfile pins and a changelog fragment, with uv.lock
regenerated by uv lock.

Bundled versions: policyengine 6.4.1, policyengine-core 3.33.0,
policyengine-us 2.2.1, policyengine-uk 2.130.1 (was 2.126.12) and
spm-calculator 1.0.0. Certified data: US populace-us-2024-spm-20260915
and 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: 7,330 variables, 29,306 parameter nodes,
104,171 parameters and 1,194,357 parameter values. This assertion
failed #3939's cross-database integration job.

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

This branch is being deployed

1 in progress 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