Skip to content

Stage 12A: Sign in to app v3 and save work to the account - #1252

Merged
anth-volk merged 54 commits into
mainfrom
feat/v3-user-login
Oct 16, 2026
Merged

anth-volk merged 54 commits into
mainfrom
feat/v3-user-login

Conversation

@anth-volk

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

Copy link
Copy Markdown
Collaborator

Fixes #1253

Summary

Stage 12A of the unified API migration, app side. App v3 (the flagship workspace, US and UK) gets a standard sign-in flow using Auth0 social sign-on, and every v3 reform and report is saved through the Public API: signed in to the account, signed out to this browser's unauthenticated user.

Only app v3 gets accounts. The legacy calculator is unchanged from main and stays browser-only. Canada, Nigeria and Israel stay browser-only too, and so does v3 with accounts off. Everything sits behind NEXT_PUBLIC_AUTH_ENABLED, which is off by default.

  • Two states: signed in and signed out.
    • The state comes only from the Auth0 session. The auth provider is mounted only in the v3 workspace, and shows a loading state while Auth0 restores the session, so nothing below it sees an in-between state.
    • Tokens are held in memory, refresh tokens rotate, and the callback route only returns to same-origin paths.
    • The menu identity comes from the ID token. If a token can't be renewed, the user is signed out locally and the menu asks them to sign in again; their unsaved draft stays for them. Any other token failure (timeout, network) leaves the session signed in and reports that sign-in could not be reached.
    • The draft is kept with the account that had it signed in, by a fingerprint of its subject. A page that loads signed out drops a draft left by a session that expired between visits, a different account signing in drops the previous one's, and signing out clears it.
    • The callback drops ?code&state once handled, so a reload does not replay a used code.
  • Signed in:
    • v3 reforms are /v2/user-policies rows with provenance, each pointing at a v1 policy.
    • v3 reports are /v2/user-reports rows with the v3 fields (title, source note, provisions with their growth settings, validation, the reform it was run from, the saved reform it was compared with, budget-window years), each pointing at a v1 report. The report page rebuilds its header, budget window and comparison from the row, so they survive a reload and open on any device; the comparison's name is the linked saved reform's, looked up by the Public API, so no name is stored on the report.
    • The server stamps the owner, so the app never sends a user_id.
    • Failures are ordinary per-request errors; nothing falls back to browser storage. The API stores accept only API IDs (a browser-only ID is refused before any v1 policy is created for it).
  • Signed out (accounts on, US and UK): the same Public API routes and stores, as this browser's unauthenticated user.
    • On the first visit the browser gets a random UUID, kept in localStorage (pe-browser-user-id) with no expiry and sent as PolicyEngine-Unauthenticated-User. The Public API keeps the work under an unauthenticated user with that ID, created on its first write. The UUID is the credential for that work, so it never goes in a URL or a log.
    • Nothing saved lives only in the browser, and nothing is copied anywhere at sign-in.
  • Upgrade at sign-in: the callback calls POST /v2/me/upgrade with the session and the browser's user. A first sign-in converts that user into the account (same user, so every saved reform and report stays); an existing account takes over its work. The browser then starts over with a new UUID, so the next person signed out here starts empty. A failed upgrade keeps the UUID (so the next sign-in tries again) and offers "Try again".
  • Accounts off, and outside the US and UK: v3 uses only its own localStorage keys (reforms, pe-flagship-reports, pe-flagship-report-meta), as on main; saving a reform creates its v1 policy first and keeps the policy ID. Nothing from that storage is copied into the Public API.
  • No labels on base records: the v1 policies v3 creates (report runs, saved reforms, the bill page's calculation) carry only their changes; names and titles live only on the user's records. The v1 policy route deduplicates on country, changes and label, so identical changes now share one policy, one pair of simulations and one report per year. Simulations and reports never carried labels.
  • Everywhere: v3 no longer reads or writes the legacy calculator's keys, and no longer opens old sur- links, which only ever resolved in the browser that created them.
  • Report links are shareable:
    • With accounts (US and UK, signed in or out), a report's URL is its /v2/user-reports row ID. Whoever opens it (signed in, signed out, or another user) reads the row through the Public API's credential-free /v2/shared-reports/{id} and sees the owner's title, source note, provisions, budget window, comparison and validation pin over the v1 report the row points at.
    • Only the session that ran the report links "Edit the reform" to its saved reform and pins the validation; anyone else editing it saves a new reform of their own.
    • Identical changes share one v1 report, so its number never named one run. Where accounts exist, a link carrying a v1 report's number is an old one and no longer opens.
    • Without accounts (sign-in off, or Canada, Nigeria and Israel) nothing is shared, so the link stays the v1 report's number, and others see the results with the provisions rebuilt from the policy, without the title.
  • UI: sign-in or the user menu appears in the v3 top bar, US and UK only. The account page (/[country]/account, in the v3 shell) offers "Download my data" and "Delete my account"; on ca, ng and il it points to the US and UK calculators instead of offering sign-in. The sign-in upgrade notice is a v3 panel under the top bar, and the top bar's "More" menu sits with Build and Reforms.
  • Every reform report is linked to a saved reform:
    • Build's run button is now "Save and run {year} report". It saves the draft first, exactly as Save does (changing the reform the draft came from, or saving a new one), then runs the report linked to that reform. A failed save runs nothing, and it waits while a Save is pending.
    • If the reform the draft edits is gone from this session's store (deleted, or belonging to another user), saving saves the draft as a new reform.
    • "Edit the reform" on a report that failed to calculate keeps the saved reform it was run from, so saving the edit changes that reform.
    • "View full impact report" on a saved reform links the report to it, and runs the reform as Build does: dated changes go with it (it used to send only each parameter's first value), and parameters left at current law stay out.
    • A reform whose values all match current law can't be run from the Reforms page, and the report page's Recompute waits until a value differs from current law; both say why, as Build already did.
    • Bill reports from the "In Congress" tab are unchanged and unlinked: an enacted bill is compared with the law before it.
  • Reforms list: each reform's v1 policy loads separately and is cached for the session, so one failure shows "Changes could not be loaded" on that reform instead of breaking the list.
  • BASE_URL reads NEXT_PUBLIC_API_URL / VITE_API_URL, defaulting to production, so staging builds talk to the staging API.
  • Removed: the Next.js Drizzle / Neon database path (api/{reforms,reports}, calculator-app/src/db, DATABASE_URL, PGlite tests), including the edit-token ownership that landed on main on 13–14 October (libs/flagship/ownedItems, migration 0003_anonymous-ownership). v3 storage is the Public API (or the browser with accounts off), so that path is not merged.
  • Merged from main a third time (15 October): the v3 audit (App v3: fix where the feature PRs overlap #1273, Validation: baseline comparisons for the programs the reform changes #1274, Reforms: tracker bill stages stay the same on every load #1277, Bill feed: complete stages, matching run settings and named fiscal notes #1278, Fix v3 bill budget label, reform delete, chart labels and accessibility #1280, Fix v3 report content: shared-link dates, NaN labels, people wording, downloads, phone names, district decimals #1281, Report: run and report lifecycle fixes from the v3 audit #1283, Build: fixes from the v3 audit #1284, v3 run bar: hold the time estimate inside the API's reported stage #1285), run progress from the API, a complete report opening from its stored output and a failed run recorded on its v1 report, the wait for the reform policy, the report year switcher and All years view (Report: switch a budget window report between its years #1292), dated edits in the adjust panel (Report: adjust each period of a multi-year reform; keep the window #1294), each country's own draft, and Your reforms' read-only reform detail with its parameters table, "View results", "Re-run" and delete prompt (Reforms: view or re-run a saved report; parameters as a table #1288). A report opened by its link loads the v1 report the saved report names (legacy_report_id) and gets the same stored output and run lifecycle as a link without accounts. Adapted to this PR:
    • "View results", "Open" and "View full impact report" go to the report's link (reportLink), never the v1 report's number.
    • Re-run keeps an account report's year (read from its v1 report), budget window (the row's budgetWindowYears) and comparison (the row's baselineReformId: the baseline policy the report ran, named from the saved reform), and links the new run to the same saved reform. A report kept in this browser still reads its run details by the v1 report's number, which is its link. When the comparison's policy can't be found, nothing runs rather than compare with current law. "View full impact report" reopens an account report of the same values the same way.
    • Build's run stays "Save and run" (a failed save runs nothing); it now saves only the values that differ from current law and leaves a reused reform, and its name, as it is. The reform detail keeps this PR's guards (changes that did not load, or a reform that changes nothing, cannot run).
    • Signing out, a signed-out load after an expired session, and another account signing in now clear every country's draft, not only the one in hand. The budget-window years asked for on a saved report's link stay in memory for the page session, not in browser storage, since the link opens the report for anyone.
    • A failed run and the stored output are written only to the v1 report (as stored output already was), never to the user's report row; the validation pin stays the only write to the row, and only from the session that ran it.
  • Merged from main again (15 October): comparing a reform with a saved reform (Build's baseline picker, the run, the report's labels and adjust panel), growth settings on provisions, the versioned report schema for stored records and run details (and the "details can't be read" state), report load errors with not-found and retry, phone layouts, the bill filters and the chart fixes. Account rows are read through the same report schema as browser records. The baseline picker lists this session's saved reforms (the account's or this browser's). Neither the reform nor the baseline policy carries a label. The calculator-app report routes, their tests, and the schema helpers that only served them stay removed.
  • Merged from main (14 October): the run bar (year and budget window, population with states and congressional districts), indexing in a pop-up, the editable title; "a run keeps its reform" (reuse a saved reform with the same values), reports listed under each saved reform, in-place edits that keep dated values, a saved reform's report run in the reform's own year; the report page's renaming, population and "Edit in Build" in the adjust panel, progress bar, budget window and no-change notice. Store methods follow main's names (findMine(countryId), findByApiReportId(apiReportId)), and names sent to the Public API are clamped to its 255-character limit (notes to 10,000) so a long bill title can't fail a save.
  • Dependencies: the Auth0 SDK dependencies are pinned to releases more than two weeks old (@auth0/auth0-spa-js 2.27.0, @auth0/auth0-auth-js 1.15.0).

Environment

Auth0 (policyengine.uk.auth0.com, custom domain auth.policyengine.org pending DNS) has two v3 single-page apps, each with Google, Microsoft and GitHub sign-in and no password sign-up:

  • "PolicyEngine v3 staging" (aHhxCvUHwCwCNZekW4yRX6hiGCF9kFYS) for this branch's preview alias (policyengine-calculator-next-git-feat-v3-u-70c7ce-policy-engine.vercel.app) and http://localhost:3001;
  • "PolicyEngine v3 production" (LZBiZVIVG3sLzX66neu2RsE7rUDBDHjX) for https://app.policyengine.org.

Both allow <origin>/us/callback and <origin>/uk/callback, log out to <origin>/us and <origin>/uk, and rotate refresh tokens (30 days absolute, 7 days idle). The audience is https://api.policyengine.org/, now with offline access.

Vercel (policyengine-calculator-next):

  • Set: NEXT_PUBLIC_AUTH0_DOMAIN=auth.policyengine.org, NEXT_PUBLIC_AUTH0_CLIENT_ID and NEXT_PUBLIC_AUTH0_AUDIENCE=https://api.policyengine.org/, in Production and in Preview for this branch only. They do nothing while sign-in is off. There is no fixed staging alias (the one custom environment, app-preview, serves another review), so this branch's preview is the staging app.
  • After the Public API PR is on staging and the custom domain is verified: set NEXT_PUBLIC_AUTH_ENABLED=true and NEXT_PUBLIC_API_URL=https://policyengine-api-staging-43j3ckhyaa-uc.a.run.app in Preview for this branch, and redeploy it. Until then the preview stays browser-only, so reviewers can use it.
  • Production: sign-in stays off until rollout. (NEXT_PUBLIC_FLAGSHIP_SHELL is not set in Production either.)
  • Clean-up: none; the project has no DATABASE_URL.

Known limitations

  • On a shared browser, whoever signs in next takes over the work saved there while signed out.
  • Clearing the browser's storage loses the UUID, and with it access to that browser's signed-out work (which stays in the database, unreachable).
  • Work saved in browser storage before accounts are turned on (US and UK) is not copied into the Public API and no longer shows.
  • A tab still open signed out after another tab signs in gets 401s until it reloads: its UUID now belongs to the account.
  • If the upgrade succeeds on the server but its response is lost, the browser keeps the converted UUID; signed out it then gets 401s until the next sign-in, whose upgrade returns none and starts the browser over.
  • A report run fails if its record can't be saved, signed in or out.
  • Anyone holding a report's link can read its title, source note and provisions. A link can't be made private, and deleting the report (or the account) breaks it.

Testing

  • bun install --frozen-lockfile passes with bun 1.2.21.
  • app: lint (eslint and stylelint) and typecheck pass; prettier passes on every file this PR touches. vitest: 4870 passed, 10 skipped, after the second merge of main. One test was updated for main's new adjust-panel labels.
  • calculator-app: typecheck and next build pass, including the /[countryId]/account and /[countryId]/callback routes.
  • After the shareable links (bun 1.4.2): app lint, typecheck and prettier pass, vitest has 4889 passed, 10 skipped, and calculator-app typecheck passes. next build was not rerun.
  • After the third merge of main (bun 1.4.2): app lint and typecheck pass, prettier passes on every file the merge touched, and vitest has 5160 passed, 10 skipped. calculator-app typecheck and next build pass.
  • New tests cover:
    • re-runs and reopening after the third merge: an account report's re-run keeps its year (from the v1 report), window and comparison (the policy it ran, named from the saved reform) and links the same reform; a browser report's re-run reads its run details by the v1 report's number; a comparison whose policy can't be read falls back to the saved reform's policy, or runs nothing; "View results" and "View full impact report" open the row's link; the compared reform is named after a reload; set-aside drafts are cleared with the session and a saved report's requested years stay out of browser storage;
    • shareable links: a saved report's link opens for someone who didn't run it (sign-in off, or signed out) with the owner's title, sending no credential; a deleted report is not found; a v1 report link where accounts exist and an old sur- link no longer open, while a v1 report link with sign-in off still does; a signed-in run opens at its row's link; "Open" in Your reforms follows the row ID (or the v1 report without accounts); someone else's report shows the owner's validation pin and pins nothing; the session's own row is read by link and another user's is not;
    • the provider's restore gate, the two states, the session-ended path (draft kept), an unreachable sign-in service, the draft dropped after a session that expired between visits or when another account signs in, and the used code leaving the address bar;
    • store choice: the account when signed in, the browser's unauthenticated user when signed out (with the header and no bearer), the browser with accounts off or outside the US and UK;
    • the browser's user ID (made once, kept, replaced after an upgrade, and page-only when storage is blocked);
    • the upgrade at sign-in: converted or merged (notice, new browser ID), nothing to upgrade (no notice), and a failure (browser ID kept, "Try again" upgrades it);
    • a signed-out save creating the v1 policy and keeping its ID, a rename keeping it, a parameter change replacing it, and a failed policy creation storing nothing;
    • a browser-mode run writing only v3 keys, and v3 reading no legacy key;
    • the sur- not-found message and the browser-ID message;
    • per-reform load failures;
    • save-then-run from Build (save before run, the report linked to the saved reform, an edited saved reform changed rather than copied, a failed save running nothing, no second save while one is pending, a missing reform saved as new), and a saved reform's run linked and keeping its dated changes;
    • no report run for a reform or adjustment that changes nothing, and the account page on ca, ng and il;
    • the legacy headers having no sign-in UI;
    • the v2 client, the API stores and the mappers, including the Authorization and unauthenticated-user headers and body shapes;
    • the budget window on the report row: sent for a window of two or more years, omitted for one, and read back into the report's run details from the report's year;
    • the compared reform: linked on the row only when it is an account reform, recorded by a run, never sent by name, and carried into the rebuilt run details;
    • growth settings round-tripping through the wire mapping, and account rows read through the report schema (an unreadable row left out of a list, and a typed error when opened).
  • No end-to-end run against Auth0 or the staging API yet. That depends on the API PR and the Auth0 setup.

Related

🤖 Generated with Claude Code

anth-volk and others added 7 commits October 12, 2026 23:58
- BASE_URL now comes from VITE_API_URL (calculator-app: NEXT_PUBLIC_API_URL),
  defaulting to https://api.policyengine.org, so staging builds can use the
  staging API.
- next.config.ts maps NEXT_PUBLIC_API_URL, NEXT_PUBLIC_AUTH_ENABLED and
  NEXT_PUBLIC_AUTH0_{DOMAIN,CLIENT_ID,AUDIENCE} (or their VITE_* spellings)
  onto import.meta.env.VITE_* for the shared app code.
- Add @auth0/auth0-react to app and calculator-app.
- Document the calculator environment in calculator-app/.env.example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign-in (inert unless NEXT_PUBLIC_AUTH_ENABLED=true and Auth0 is configured):
- libs/auth: AuthProvider wraps @auth0/auth0-react (memory cache, rotating
  refresh tokens with fallback, audience + offline_access) and loads the
  PolicyEngine user from GET /v2/me for US/UK; useAuthStatus() exposes
  status, user, signIn, signOut and getAccessToken. Without the provider,
  or with sign-in off, status is 'disabled'.
- Mount the provider in [countryId]/providers.tsx and add the
  /[countryId]/callback route that completes sign-in and returns to
  appState.returnTo (same-origin paths only).

Account storage:
- v2UserClient: bearer-token fetch wrapper for ${BASE_URL}/v2/..., parsing
  the {status, message, result} envelope and throwing V2ApiError(status).
- useAssociationBackend(): API stores only when signed in with a loaded
  PolicyEngine user AND the country is US or UK; otherwise localStorage,
  unchanged. Queries wait while the session is restored, and query keys
  carry the PolicyEngine user ID (or 'anonymous') so caches split.
- Rewrite ApiPolicyStore/ApiHouseholdStore/ApiSimulationStore/ApiReportStore
  and their adapters for /v2/user-{policies,households,simulations,reports}:
  numeric legacy_*_id <-> string IDs, the association UUID as id,
  list/get/create/update/delete, never a user_id. The API report store has
  no createWithId (shared-report saves POST {legacy_report_id}), and local
  sur- IDs resolve through this browser's import map or fall back to
  localStorage so old links keep working.
- Replace every hard-coded isLoggedIn = false and the local simulation
  store in createReportSimulations; pass the session's user ID instead of
  MOCK_USER_ID. Saved geographies stay in localStorage for everyone; drop
  the unused ApiGeographicStore and dead association payload types.

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

Signed in (US/UK):
- Reforms are /v2/user-policies rows. Saving materializes the parameters
  as a v1 policy (POST /{country}/policy, which dedupes) and stores
  {legacy_policy_id, name, baseline, provenance}; parameter edits create a
  new v1 policy and repoint legacy_policy_id; reading rebuilds parameters
  from the v1 policy with the app's policy -> parameter conversion. A reform
  kept only in this browser is added to the account when saved.
- Flagship report records are /v2/user-reports rows with the v3 fields
  (title <-> name, sourceNote, provisions incl. dated intervals, validation,
  reformId <-> user_policy_id). runFlagshipReport writes exactly one
  association right after the v1 report exists; the report page reads the
  account record for provenance on other devices.

Signed out (and outside the US and UK) the v3 stores use localStorage
exactly as before, now without first trying the never-provisioned
/api/reforms and /api/reports routes.

Remove those Next route handlers, calculator-app/src/db (Drizzle schema,
migrations, postgres-js client), DATABASE_URL, the PGlite route tests, the
drizzle/postgres/neon/pglite dependencies and db:* scripts, and the
storeBackend switch they relied on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- AuthMenu: "Sign in" when signed out, a user menu (initial, My account,
  Sign out) when signed in, nothing when sign-in is disabled. Shown in the
  calculator header (desktop and mobile) and in the flagship top bar, which
  replaces that header when the flagship shell is on.
- Sidebar "My account" links to the account page when sign-in is enabled.
- Account page (/[countryId]/account, also replacing the Vite router
  placeholder): user details, "Download my data" (GET /v2/me/export),
  "Delete my account" (confirm, DELETE /v2/me, sign out), and saving this
  device's work to the account.
- First sign-in import (US/UK): when the browser holds saved work the
  account lacks, offer "Save the work on this device to your account?".
  Gathers the policy/household/simulation/report associations, reforms,
  flagship records and provenance stash; materializes reforms without a
  v1 policy; merges a flagship run's sur-/lfr- records into one report;
  POSTs /v2/me/imports per country in chunks of at most 500 items (and
  about 1 MB); reports Canada/Nigeria/Israel items as staying on the
  device; persists the id_map (per user) so old local links resolve; and
  never deletes local data. "Not now" is remembered.
- README: how sign-in and saved work behave, and the variables to set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Account policy rows include policies saved from the legacy builder. v3
reforms always carry provenance and legacy policies never do, so the
reform store now reads only rows with provenance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@auth0/auth0-react 2.27.0 resolved @auth0/auth0-spa-js 2.28.3 (published
2026-10-09) and @auth0/auth0-auth-js 1.16.1 (2026-10-02). Override them to
2.27.0 and 1.15.0, both published in September. Re-locked with the
pinned bun 1.2.21.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API reports items it cannot parse as skipped with a null client_ref.
Keep them in the summary but out of the per-ref skip bookkeeping.

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

vercel Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
policyengine-calculator-next Ready Ready Preview Oct 15, 2026 8:43pm UTC
policyengine-website Ready Ready Preview Oct 15, 2026 8:43pm UTC

Request Review

anth-volk and others added 9 commits October 13, 2026 15:53
Saved-work queries wait until the session is restored, because until then
it is not known whether saved work is in the account or in this browser.
A waiting TanStack query is neither loading nor holding data, so report
pages said "Report not found", flagship sur- links were marked
unresolvable, and lists showed their empty state. With sign-in enabled
this hit signed-out visitors too.

The association hooks now go through useSessionGatedQuery, which reports
a query that is waiting for the session as loading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
If GET /v2/me failed (after one retry) the session became 'authenticated'
with no PolicyEngine user, so the store switch quietly chose
localStorage: the header still showed the user menu, new saves stayed on
this device, and nothing retried until a remount.

- A US or UK session whose PolicyEngine user could not be loaded is now
  'account_error'. It gets the account stores backed by a client that
  rejects every call with AccountUnavailableError, so lists and reports
  show an error and saves fail with a clear message instead of going to
  localStorage. A flagship run is refused up front, as it is while the
  session is still being restored.
- /v2/me retries three times with backoff (1s, 2s, 4s), then again when
  the window regains focus or the network comes back, and on
  retryAccount.
- The user menu marks the avatar, says the account could not be loaded,
  and offers "Try again" alongside "Sign out"; the account page offers
  both too.
- selectAssociationBackend takes the user as-is, removing the
  `{ id } as MeItem` cast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An item the import sent that came back in neither id_map nor skipped
(the server reports unparseable items without a client_ref) stayed
pending, so LocalDataImportPrompt reopened on every visit, and closing
it after an import recorded nothing.

- Every item the server did not save is now recorded as skipped, with
  the server's reason or as invalid, and appears once in the summary.
- Closing the prompt after an import ran ("Done", or closing after a
  failure) is remembered like "Not now"; the account page still offers
  what is left.
- The summary only says that what an item refers to no longer exists for
  unknown_legacy_reference skips; other skips read "could not be saved".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed in, each account store handled an ID that only this browser knows
(saved while signed out, never imported) differently: the report store
read and updated the local copy, the policy, household and simulation
stores answered 404, and the reform store created a fresh row without
recording the rf- mapping, so a later import added the reform again.

One policy now lives in AccountAssociationRoutes and every store follows
it: an imported local ID resolves to its account row; any other local ID
is read from this browser; changing it copies it into the account under
its local ID as client_ref and records the mapping (on a 409, the
existing row with that client_ref is updated instead); deleting it
deletes this browser's copy.

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

- A signed-in run stashed its title, source note and provisions in
  localStorage under the v1 report ID, where whoever used this browser
  next would see them on that report. Signed-in runs now keep that
  provenance in memory for the session; the account record carries it
  everywhere else.
- Signing out clears that stash and the draft reform, which may hold an
  account reform loaded for editing.
- Import bookkeeping stays filed per PolicyEngine user. Items one account
  imported from this browser are no longer offered to a different
  account that signs in here; deleting the importing account releases
  them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The client's fallback for a 422 said "Something this refers to no longer
exists", but the Public API also answers 422 for request validation, and
the 409 fallback said every conflict was "managed elsewhere", although a
duplicate client_ref conflicts too. Both now name the likely causes
instead of asserting one. (The `{ id } as MeItem` cast went with the
account-error change.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While the session was being restored (and /v2/me now retries for up to
about 7s), the store hooks handed out the localStorage stores, so a
signed-in user's save in that window landed in this browser.

- The auth context gains settledSession(): it answers once the status
  leaves 'loading', with the state the session settled in.
  useAssociationBackendResolver turns that into the backend for the
  country.
- Every saved-work store the hooks hand out (policies, households,
  simulations, reports, reforms, flagship report records) now forwards
  each call to the store for the settled session. That covers the
  association mutations and the direct writers: the report builder's
  submission and modify hooks, useCreateReport, ReportAdjustPanel,
  DraftHeader and the Reforms page. useSaveSharedReport and the flagship
  run resolve the backend the same way; the run no longer errors while
  the session restores.
- Signed-out visitors wait only for the restore, then save locally.
- The v2 client asks for the access token at request time, so a request
  that waited carries the signed-in user's token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useCreatePolicy, useCreateHousehold and useCreateSimulation create the v1
artifact and then save its association, catching an association failure
with console.error only. That was harmless while saves went to
localStorage; with account storage a 413, a 503 or an unavailable
account means the item silently is not saved.

The failure is now reported through reportSaveFailure and shown by
SaveFailureNotice, a FloatingAlert mounted once in each app shell so it
stays up after the builder dialog closes. The builder still carries on
with the created artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 6 commits October 13, 2026 17:04
- deferredStore replaces the six hand-written forwarding stores. It
  forwards each listed method to the store for the settled session, with
  that store as `this`; the method map's type requires every method.
- useSavedWorkStore replaces the four near-identical
  use*AssociationStoreForMode bodies and backs the reform and flagship
  report stores too. The test-only resolveStore is gone; the selection
  tests now check which store is used by what it calls.
- The account report store implements createWithId as create (the
  account assigns IDs), so createWithId is no longer optional and the
  create mutation needs no casts.
- The local delete methods name their callback parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed in, the reforms list fetched every reform's v1 policy through
Promise.all, so one 5xx or 429 failed the whole list: "Yours" showed its
error state and ReportAdjustPanel aborted its run. Every list load also
refetched every v1 policy.

- ApiReformStore.findByUser settles each reform separately. A reform
  whose v1 policy does not load is listed with parametersUnavailable set
  and no parameters.
- The Reforms page labels that card, explains it in the detail view with
  a "Try again" that reloads the list, and disables run, save, Build and
  duplicate for it, which would otherwise act on an empty reform.
  Delete still works.
- ReportAdjustPanel never matches such a reform, so it saves the
  adjusted set as a new reform and runs.
- v1 policies never change, so useReformStore fetches each one through
  the query cache, keyed by country and policy ID.

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

- AccountAssociationRoutes.writeCountry is the one rule for where a
  write goes: the item's country when it has account storage, otherwise
  the store's. The four association stores use it, and so now do the
  reform store (including where it materializes the v1 policy) and the
  flagship report store, which wrote in the page's country.
- promote's 409 recovery lists and patches in the country it created in,
  not the page's.
- useUserReportById listed saved simulations, policies and households
  under the report row's own user ID: '' until the row loaded, firing
  full account list calls under an empty key, and 'anonymous' for a
  local report read while signed in, caching account rows under the
  local user. It now lists them for the session's user, once the report
  row has loaded. (The report-by-ID key itself is unchanged.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reforms and builder policies share the account's /v2/user-policies rows.
Signed out they live in separate stores, so the legacy Policies list and
the report builder never showed reforms; signed in, they did.

UserPolicyAdapter.isReform (the row carries provenance) now decides,
for both lists: the policy store's list leaves reforms out, the reform
store lists only reforms. Lookups by v1 policy ID and by association ID
are unchanged and still find reform rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The import left out anything another account had imported from this
browser. Anyone signed out on the browser can already see those items,
so hiding them protected nothing, but it kept a second person from
claiming their own signed-out work once someone else had imported it.

Each account is now offered the browser's signed-out items it has not
imported (or had skipped) itself; readRefsImportedByAnyone is gone. The
prompt and account page say these are items saved on this browser while
signed out, and the prompt says anyone using the browser signed out can
see them.

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

- A write waiting for the session to be restored used to wait forever
  if the auth provider unmounted first (for example on a country switch,
  which remounts it). Its waiters now fail with SessionClosedError, which
  says the save did not happen and to try again.
- The import summary gives skips with the Public API's
  client_ref_conflict reason (the account already holds the item under
  another country) their own line instead of a bare "could not be saved".

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

Brought over from main: comparing a reform with a saved reform (Build's
baseline picker, the run, the report's labels and adjust panel), growth
settings on provisions, the versioned report schema for stored records
and run details (with the unreadable-details state), the report load
error, not-found and retry states, phone layouts, the bill filters and
the chart fixes.

Kept from this branch: the account-backed stores. Account report rows go
through the same report schema as browser records; the compared reform is
saved as a link (baselineReformId), and its name comes from that reform's
own record. Neither the reform nor the baseline policy carries a label.
The calculator-app report routes and their tests stay removed, along with
the schema helpers that only served them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A report's link is now its /v2/user-reports row ID, not the v1 report's.
Whoever opens it, signed in, signed out or another user, reads the saved
report through the Public API's credential-free /v2/shared-reports route and
sees the owner's title, source note, provisions, budget window, comparison
and validation pin over the v1 report the row points at. Only the session
that ran it links the reform it was run from and pins the validation.

Identical changes share one v1 report, so the v1 report's number never named
one run. Where accounts exist, such a link is an old one and no longer opens.
Without accounts (sign-in off, or outside the US and UK) nothing is shared, so
the link stays the v1 report's number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 3 commits October 15, 2026 21:46
…audit and report re-runs

Brought over from main: the v3 audit fixes (#1273, #1274, #1277, #1278,
#1280, #1281, #1283, #1284, #1285), run progress from the API, a report
that opens from its stored output and records a failed run, a wait for
the reform policy, the report year switcher and All years view (#1292),
dated edits in the adjust panel (#1294), each country's own draft, and
Your reforms' read-only reform detail with a parameters table, "View
results", "Re-run" and the delete prompt (#1288), with the other merged
fixes (#1289, #1290, #1293, #1295, #1296).

How the conflicts were resolved:

- Report page: a link is still a saved report's ID, read through the
  credential-free shared-report route, with the v1 report's number only
  without accounts. The v1 report it names (the shared row's
  legacy_report_id) gets main's run lifecycle: stored output, recorded
  failures, the household guard, the wait for the reform policy (for any
  link that resolved), the unknown-baseline state and the year switcher.
- Reforms page and ReformReports: main's read-only detail, parameters
  table, delete prompt, View results and Re-run, on this session's stores
  and session-scoped query keys. "View results", "Open" and "View full
  impact report" go to reportLink(report), never the v1 report's number.
  The detail keeps this branch's guards: a reform whose changes did not
  load cannot run and says so, a reform that changes nothing cannot run,
  and the run sends what Build would. A record without a year reads it
  from the API's record.
- Build's run bar: still "Save and run", which saves first and runs
  nothing when the save fails; it now saves only the values that differ
  from current law, leaves a reused reform (and its name) as it is, and
  names a budget window's years. A new reform has nothing to save when
  nothing differs.
- Adjust panel: main's dated edits, budget window and unknown-baseline
  guard on this session's reform store; recompute still waits until a
  value differs from current law.
- draftReform: loadProvisionsIntoDraft takes both the saved reform it
  edits and main's window and source.
- Tests: main's run-hook tests sit beside this branch's store tests; the
  run bar test that ran a report after a failed save is dropped, as a
  failed save runs nothing here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Your reforms' Re-run and "View full impact report" came from main reading
a report's details by its v1 report's number, which finds nothing for an
account report: its details are kept under its link (the row ID), and
only in the session that ran it. A re-run of one ran this year's report
against current law, and an existing report of the same values never
reopened.

- Re-run reads the run's details by the report's link first. Without
  them it takes the year from the v1 report, the budget window from the
  record (budgetWindowYears) and the comparison from the record's
  baselineReformId: the baseline policy the report ran (from its
  simulation), named from the saved reform. When that policy can't be
  read it uses the saved reform's own policy, and when neither is known
  it runs nothing rather than compare with current law. The new run is
  linked to the same saved reform. A report kept in this browser still
  reads its details by the v1 report's number, which is its link.
- "View full impact report" reads an account report's year from the v1
  report before looking for one to reopen, and counts a report linked to
  a compared reform as one against a saved reform.
- The report list names the reform an account report was compared with
  from this session's saved reforms when the run's details are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s and report years

Two things main now keeps in this browser outlived a signed-in session,
where the next person on a shared browser would find them:

- Each country's draft (#1284) is set aside under its own key when
  another country's is started. Signing out, a signed-out load after a
  session that expired, and another account signing in cleared only the
  draft in hand; they now clear every draft, as they did before there
  was more than one.
- The years asked for in a report's budget window (#1292) are kept in
  this browser, keyed by the report's link. A saved report's link opens
  the report for anyone, so those years now stay in memory for the page
  session instead; a link without accounts keeps them as main does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 6 commits October 16, 2026 00:00
… main's naming and reform badges

main gained #1297 to #1306 (name before run, the run bar and window in the
sidebar, library status, district details, calibration notes, household
add button, scorecard notes, merge leftovers). Three files conflicted:

- DraftHeader: kept this branch's save-then-run mutation in place of
  main's runAndKeep, and took main's change to it: an unnamed reform runs as
  UNTITLED_REFORM (draft.label.trim()). main's "Name it" box and taller years
  picker merged as they are.
- ReportAdjustPanel: took main's Select imports for its window picker; the
  panel keeps this branch's useReformStore instead of getReformStore.
- Reforms.page: a reform card keeps this branch's "Changes could not be
  loaded" eyebrow and gains main's latest-report status badge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An account report whose compared reform was deleted (the Public API sets
its baseline_user_policy_id to null), or a recompute of someone else's
report, has no saved-reform link. Re-run then compared with current law,
'View full impact report' could open it as the reform's own report, and
Your reforms dropped 'Compared with'. The report's baseline simulation
still shows the policy it ran: when that is not current law, re-run keeps
it, the match skips the report, and the row reads 'Compared with a saved
reform'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
'Save and run' read the draft from the latest render when the save
answered (TanStack Query v5 updates a pending mutation's options), so an
edit made while saving ran values the linked saved reform did not hold.
The draft and its comparison are now fixed when the button is pressed and
passed as the mutation's variables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With sign-in off, and outside the US and UK, every v3 page wrote a
pe-browser-user-id it never sent. It is now made only for a signed-out
session in the US or UK; otherwise the client sends no credential. Where
crypto.randomUUID is missing (an insecure context, older Safari) the ID is
built from crypto.getRandomValues instead of throwing during render, and a
stored value the Public API would refuse is replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A page that loaded signed out deleted every draft an account had left, but
a restore that only failed (a timeout, a lost connection, Safari's
cross-site limits) signs the account back in on the next load. The drafts
are now held out of sight for that account instead: they come back when it
signs in again, and go when another account signs in, on sign-out, or after
seven days. A signed-out session still never sees them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
clampStoreText cut by UTF-16 code units, so a name over 255 characters
with an emoji at the cut sent half of one, which the Public API refuses
with 422 after the run's v1 report exists. It now counts and cuts code
points, as the API counts characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s value coercion

main gained #1305 (type and check policy values as v2 does), #1307 and
#1308. Three files conflicted:

- runReport: createReformPolicy keeps this branch's signature without a
  label (names stay on the user's records) and takes #1305's parameters
  argument and coercePeriods. RunFlagshipReportArgs and the run keep both
  accountReportStore and parameters.
- useBillEconomy: createReformPolicy(countryId, provisions, parameters).
- useRunFlagshipReport: keeps both the report store and the parameters.

#1305's createReformPolicy tests drop the title argument; their assertions
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Current law reads 0 until the model's metadata loads, and the report list
renders before then. Comparing a report's baseline policy with that 0 made
every current-law report read 'Compared with a saved reform' until the
metadata arrived, or for good if it failed. A current-law ID that is not a
real policy now leaves the comparison unknown, for every caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk merged commit c82b825 into main Oct 16, 2026
12 checks passed
@anth-volk
anth-volk deleted the feat/v3-user-login branch October 16, 2026 06:08

This branch was successfully deployed

2 active deployments
Preview – policyengine-calculator-next — 17956377 Deployed Oct 15, 2026 by vercel[bot]
Preview – policyengine-website — 17956377 Deployed Oct 15, 2026 by vercel[bot]
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 to app v3 and save work to the account

1 participant