Goldie Blocks

Auth Flow

Post-MVP — the rules identity verification must satisfy when it is built, and the one rule that holds today.

Post-MVP — a placeholder, not a description of the app

There is no sign-in flow. The MVP seed and the root-creation door mint sessions already authorized (the interim browser door), and the tables the earlier interview-based flow wrote — documents, documentVersions, modules, moduleVersions — no longer exist. This page keeps the rules so the contract is not re-derived later; the record of what is stored is authSessions, which governs.

The rule that holds today

Only an authorized session opens a Document (GB-171, 2026-09-06). The binding rule every Folio read and write asks — requireSessionBoundToOneOf in convex/documentSessionBinding.ts — refuses a session whose status is pending_auth, completed or abandoned, in the same wording it refuses a stranger with, whatever documentKey the row names.

A sign-in flow that wants drafting before authentication must add its status to that rule deliberately. It does not get it by default.

In plain terms: a file reference number on an unstamped slip opens nothing. The clerk checks the stamp before the file name.

Folio access is membership, before anyone signs in (GB-309, 2026-09-25). Each browser has one anonymous users row, minted the first time it creates a folio. Creating a Workspace or a root Document writes that user an owner row in folioUsers, and a Project answers to its Workspace's row. The Workspace, Project and list reads (browserCanReadFolio, requireBrowserFolio in convex/folio/containerAccess.ts) ask for that row and refuse another browser in the same wording. The session rule above still governs a Document's own reads and writes.

In plain terms: every browser gets a client number, and the register records that number against each file it creates. When the person later proves who they are, the number is attached to them, so every file comes with them.

The rules it must satisfy

Auth verifies the operator, never the document's subject

A user may draft for themselves, a spouse, or many clients. Each draft's subjects are document content; who is signed in is not. Nothing may infer one from the other.

Authentication is not a session-start gate

The challenge is placed by whoever authors the document — first (gated), in the middle (try before you save), or conditionally. That placement is the author's decision, so the flow may never assume the challenge comes first.

Secret generation, code verification, TTL and retry limits belong to the identity provider. The app stores only what connects a provider challenge to a session — provider name and challenge reference — and never a raw code or the provider's retry state.

Every attempt is written down

authEvents is the append-only audit trail: requested, attempted, succeeded, expired, abandoned. A failed attempt and a document access must both be reconstructable afterwards. This is why the table is append-only and why nothing prunes it opportunistically.

Signing in takes over what the browser drafted

Owner ruling, 2026-09-28 (reverses option D of 2026-09-25). An account is found by its verified WorkOS identity: users.workosUserId holds the token's subject, and the first sign-in mints that row. On sign-in the client sends the browser's id to home.ensureAccount, which first runs claimBrowserWork (convex/folio/accountClaim.ts):

  • every folioUsers row the browser's anonymous user holds, any role, moves to the account; where the account already holds a row on that root, the stronger role is kept (owner > builder > maker > reader);
  • Library seats (libraryAccess) move the same way, and a Library the anonymous user holds (libraries.holderId) passes to the account;
  • the claimed roots' owner column names the account, and a claimed Workspace loses its browserClientId, so Home signed out no longer finds it;
  • the anonymous user is stamped claimedByUserKey, so the claim happens once per browser: a later sign-in by another account on the same browser takes nothing. One authEvents row (browser_work_claimed) records it.

Nothing is copied or deleted except the moved membership rows themselves. If the account had no Workspace, the claimed one becomes its Workspace (no second "My Workspace" is minted); if it had one, that stays the one sign-in lands on and the claimed Workspace is a second one. A browser holding more than 1000 rows of one kind is refused rather than half-claimed.

After sign-in the callback lands on /app: Home, the account's Workspace drawn in place (owner, 2026-10-04); the older /app/my-workspace redirects there. Anything filed while signed in (a Project, a Document, a whole-Document copy) belongs to the account.

Sign-in first (owner ruling 2026-09-25, ruling a). Every /app screen needs a signed-in account: the /app layout route (src/app/routes/app.tsx) redirects a signed-out server render to /auth/sign-in, and in the browser sends a tab whose session ends there too. /, /docs, /story, /story-page, /auth/* and /callback stay reachable signed out. The server holds the same line on its own: a root a signed-in account holds a role on opens only for that account, signed in. The Document session rule (requireSessionBoundToOneOf) and the canvas's slip lookup (sessions.getByRouteKey) both ask callerMayUseRoot, so a signed-out browser, or another account, holding the Document's session slip is refused in the same wording as a stranger; the recent list (folio.reads.listRecent) and the slip read (sessions.getByAuthSessionId) ask it too, so a browser id or a slip alone names none of an account's Documents. Signed in, the caller is the account and only the account: the browser's anonymous user no longer counts (readCallerUsers), so an account can neither open nor file into a Workspace only its browser holds. Roots no account holds (a browser's anonymous work, the seed) keep the older rules, so the anonymous machinery stays in place for draft-first later.

An account's Documents open in any browser (owner ruling 2026-09-25). A Document's session slip is still per browser, but when a signed-in account opens its own Document in a browser that holds no slip, sessions.openForAccount issues one: it checks the account's folioUsers row on the Document, never the browser, and answers null to anyone else.

In plain terms: signing in opens your own cabinet, and the papers this browser left on the desk before you signed in are filed into it. The desk is then marked cleared, so the next person to sign in at it inherits nothing.

Security note: the browser id is the key to that browser's anonymous work (anyone holding it could already open that work signed out), so presenting it at sign-in hands that work to the account, as the ruling intends.

Pre-auth draft data is not lesser data

A draft may accrue personal information before anyone has signed in. It is handled exactly as post-auth data is. "Not yet authenticated" is not a reason to treat a name, an address or a date of birth more loosely.

Roles are a separate question

Authentication answers who the operator is. What that operator may see and do on a given folio is the role on its access record, and Permissions and Surfaces is the one home for the role ladder and the capabilities each role holds. It is not restated here.

The one rule worth naming at this seam: a role is granted on the folio and is never derived from the auth session. An authenticated user may hold different roles on different folios, and signing in grants none of them.

On this page