Skip to content

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

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

anth-volk wants to merge 40 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.
  • 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.
  • 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 and calls the upgrade at sign-in. 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.
  • 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 1d987fb8 and adding the baseline link and growth settings: the make test suite (tests/to_refactor, tests/unit, tests/contract and the budget-window dedupe test) has 2738 passed. mypy, ruff and the quality guards (migration contracts, rules registry, CI scripts) pass.
  • 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.
  • 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>
anth-volk and others added 3 commits October 13, 2026 17:07
…ck accurately

User-simulation, user-report, user-household, and /v2/me requests only
store and read saved work, but logged the simulation flow and compute
selection of their route groups (economy, report, household, and
old_gateway). The migration context now takes include_compute, and
request logging turns it off for the signed-in user resources while
compute routes in the same groups keep their labels.

The Stage 12A doc said the size check runs before body validation. FastAPI
parses the whole body before any dependency, so the doc now says what the
check bounds and what it does not.

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

The previous Public API revision serves /v2/user-policies and
/v2/user-households without authentication. The reviewer reproduced an
anonymous PATCH and DELETE against the migrated schema, so the documented
"rolling back the application is safe" would have handed signed-in
users' saved work to anyone with a record ID.

Add a ready-to-apply Cloud Armor proposal that denies /v2/me* and
/v2/user-* with 403 at priority 900, with the ingress change that closes
the *.run.app bypass, and rewrite the Stage 12A rollback guidance: roll
forward when possible; once any signed-in user exists, apply the guard
before shifting traffic back, accepting that saved work is unavailable
while it holds; a database downgrade stays lossy. The doc also records
that no other previous-revision route exposes Stage 12A data.

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

The compatibility test only compared column sets and nullability. It now
reflects the parent revision's schema, upgrades to head, and through the
reflected parent tables inserts a row into every table the parent maps
using only the parent's required columns, reads it back with every
parent column, rewrites every non-key column, and deletes it. It also
deletes, through the parent's users table, a user who owns rows in all
four association tables and a user_identities row, and checks that the
cascades run. It fails if head drops a parent column or adds a required
column without a server default to a table the parent revision writes;
only the two dormant association tables, which the parent never inserts
into, may gain one.

Run against the pre-fix revision that dropped country and label, the
test fails. The Stage 12A doc now describes what the test checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
App v3 now calls GET /v2/me at the sign-in callback and offers to import
the browser's signed-out reforms and reports only when that sign-in
created the account. MeItem gains account_created, true only in the
response to the request that inserted the users and user_identities
rows. Identity resolution already returns that fact as
ResolvedUser.provisioned, so it is threaded into AccountRead without
another query. PATCH /v2/me and the export's user item follow the same
rule. Concurrent first requests that lose the insert race and read the
winner's rows back report false; a Postgres test confirms exactly one of
eight concurrent first requests reports true.

The Stage 12A doc now names app v3 as the only consumer, describes the
sign-in check and the one-time import for new accounts, and notes that
/v2/user-households and /v2/user-simulations have no app consumer yet.
The contract registry and generated artifacts include the new field.

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

This reverts fd6b9b9. App v3 no longer limits the import to the sign-in
that created the account: every sign-in moves the browser's signed-out
v3 reforms and reports into the account, removes each item whose
client_ref comes back in id_map, and keeps the rest for the next
sign-in. Nothing reads account_created any more, so MeItem, PATCH
/v2/me, the export's user item, the contract registry and the generated
artifacts drop it, along with its tests.

The Stage 12A doc's Consumers section now describes the import at every
sign-in and the account page's GET /v2/me.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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

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