Skip to content

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

Open
anth-volk wants to merge 42 commits into
mainfrom
feat/v3-user-login
Open

anth-volk wants to merge 42 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 another device; the comparison's name is read from the linked saved reform, 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 URLs use the numeric v1 report ID.
  • 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 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.

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.
  • New tests cover:
    • 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 14, 2026 9:51pm UTC
policyengine-website Ready Ready Preview Oct 14, 2026 9:51pm 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>
anth-volk and others added 2 commits October 13, 2026 23:30
The draft could point at a reform its session could no longer change,
and nothing tied it to the session that made it.

- The sign-in import points the draft at the account copy of the
  browser reform it edits (via id_map), so saving after sign-in changes
  that reform. Saving a draft whose reform is gone from this session's
  store (deleted, or saved in the other sign-in state) saves it as a new
  reform instead of failing with no way out. The account store refuses
  a browser ID before creating a v1 policy for it, and the browser store
  reports a missing reform as ReformNotFoundError.
- The draft is kept with the account that had it signed in, by a
  fingerprint of its subject (never the subject itself). A page that
  loads signed out drops a draft left by a session that expired between
  visits, and a different account signing in drops the previous one's.
  A session that ends in this page keeps the draft for the person still
  here; signing out clears it as before.
- A token request that fails for any other reason (timeout, network) no
  longer reads as an ended session: requests report that sign-in could
  not be reached, and the session stays signed in.
- The callback drops ?code&state once handled, so reloading it during
  the import does not replay a used code.
- Before moving anything, the import calls GET /v2/me in the sign-in
  country, so a first sign-in records that country on the account
  whichever countries the browser's work is in.
- "Save and run" waits while a Save is pending, so the reform is not
  saved twice, and the latest save error is the one shown.
- "Edit the reform" on a report that failed to calculate keeps the
  saved reform it was run from (useReportRecord reads this session's
  record in either state), so saving the edit changes that reform rather
  than adding a copy.

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

- A saved reform whose values all match current law can no longer start
  "View full impact report" (which failed with "Could not start the
  report"); the button is disabled and the page says why, as Build does.
  The report page's Recompute likewise waits until some value differs
  from current law. Bill reports are unchanged: an enacted bill is
  compared with the law before it.
- The account page checks the country before the sign-in state, so a
  signed-out visitor on ca, ng or il is pointed to the US and UK
  calculators rather than offered a sign-in Auth0 would refuse.
- The clean-slate test also checks the legacy geography key.

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

Signed-out work no longer lives in localStorage and is no longer copied
into the account at sign-in. Wherever accounts exist (sign-in configured,
US and UK), every reform and report goes through the Public API, one way:

- On the first visit the browser gets a random UUID, kept in
  localStorage (`pe-browser-user-id`) with no expiry. Signed out, requests
  name it in the `PolicyEngine-Unauthenticated-User` header, and the
  Public API keeps the work under an unauthenticated user with that ID.
  The UUID is the credential for that work: it never goes in a URL or log.
- Signed out on the US and UK workspace, useV3SavedWork now picks the
  API stores, scoped to that browser user; the client sends either the
  session's bearer token or the browser user (V2Credentials).
- Signing in calls POST /v2/me/upgrade with the session and the browser
  user. A first sign-in converts that user into the account, 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 it for the next sign-in.
- The sign-in import, its notice, POST /v2/me/imports and client_ref are
  removed. MeItem gains user_type, and primary_country may be null.
- With sign-in off, and outside the US and UK, work stays in the browser
  as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Names and titles belong only to the user's records (user_policies and
user_report_associations), never to the shared v1 policy. The v1 policy
route deduplicates on country, change hash and label, so labelling a
policy with a report title or reform name split identical changes into
separate policies, and therefore separate simulations and reports.

createReformPolicy and materializeReformPolicy no longer take a label,
and POST /{country}/policy is sent the changes alone, from report runs,
saved reforms and the bill page's calculation. Identical changes now
share one policy, one pair of simulations and one report per year.
Simulations and reports were already created without labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The notice used the legacy calculator's FloatingAlert, which sat at the
legacy header's offset and looked out of place in the v3 workspace. It
is now a v3 panel like the report's calculation-error panel: a white
card with a light border, 12px corners and a soft shadow, floating just
below the v3 top bar; an icon in a tinted circle (teal check when work
moved, warning triangle when it did not); a heading ("Added to your
account" / "Not added to your account yet") over the message; a v3
"Try again" button after a failure; and a ghost dismiss button. Success
is announced as a status, failure as an alert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"More" sat apart on the right, pushed there with an automatic margin.
It now sits in the primary navigation group straight after Build and
Reforms, styled like an inactive nav item (same height, padding and
weight), and its menu opens from its left edge. The draft chip and the
sign-in / user menu stay together on the right.

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

main gained a second v3 storage path on 13-14 October: a Drizzle/Neon
store inside the Next.js app, with no user ids and per-item edit tokens.
This branch keeps its own storage instead (the Public API's /v2 routes,
as the signed-in account or the browser's unauthenticated user, and the
browser's own keys with accounts off), so that path is not merged:
calculator-app/src/db (schema, migrations, limits, ownership), the
api/reforms and api/reports routes, libs/flagship/ownedItems, the
ReformAdapter and reformMetadata types, and their tests stay removed.

Everything else from main comes over:
- Build: the run bar (year and budget window, population with states and
  congressional districts, Discard, Save, run), indexing in a pop-up,
  the editable title, and the new value grid.
- A run keeps its reform: "Save and run" reuses a saved reform that sets
  the same values (findMatchingReform) before saving a new one, and runs
  on the chosen region with the chosen budget window, starting the
  report's calculation and its window at once. A failed save still runs
  nothing.
- Reforms: each saved reform lists its reports; in-place edits keep
  dated values; a saved reform's report runs in the reform's own year.
  A report record saved through the Public API keeps no year, so the
  list reads it from the API report.
- Report: renaming and population in the adjust panel, "Edit in Build",
  progress bar, budget window, no-change notice; plus website content.

Store interfaces follow main's names: findMine(countryId) and
findByApiReportId(apiReportId), with no user id on reforms or records.
Names sent to the Public API are clamped to its 255-character limit
(and notes to 10,000) with main's storeText helper, so a long bill
title cannot fail a save. app.css gets stylelint's empty-line fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PavelMakarchuk added a commit that referenced this pull request Oct 14, 2026
- Drop this PR's processed_bills join from billFeed; #1264 owns it
  (processedBillFor), so a bill gets one stage. Titles now come from
  the research select, which keeps the edits clear of #1264's lines.
- Split the tracker links into BillTrackerLink and TrackerHomeButton,
  so each import sorts away from #1264's and #1252's import edits.
- Economic impacts and the validation drift check read netCost when
  no stateRevenueImpact is stored, as the headline and the tracker do.
- The Reforms header wraps on a narrow screen, so "New reform" shows.
- trackerPlace falls back to the upper-case code.
- Feed and report test data moves into fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In the US and UK the budget window asked for with a run was held only in
memory, so a reload or another device lost the report's window section.
Save its years on the /v2/user-reports record (budget_window_years) and
rebuild the run details from the record, window included, from the
report's year.

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>

This branch was successfully deployed

2 active deployments
Preview – policyengine-calculator-next — 8fcc8584 Deployed Oct 14, 2026 by vercel[bot]
Preview – policyengine-website — 8fcc8584 Deployed Oct 14, 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