# Frontend Phase 1 — Auth: phone-OTP login & role routing > **Mission:** make people able to actually sign in. Replace the inherited username/password stub with > Balinyaar's real credential — **phone-OTP** — and wire the gate that every other screen sits behind. > Build the customer login (A1), the OTP screen (A2), the family↔nurse login switch (B1/B2 entry), and > the **role router** that reads `/me` after sign-in and sends a customer to the family app, a nurse to > the nurse app, and a brand-new user with no role to role selection. After this phase the app has a real > front door; f2-b3 (onboarding & profiles) and every authenticated screen build on it. > > **Track:** frontend · **Depends on:** [`frontend-phase-0`](./frontend-phase-0.md) (shells, the OTP/phone > composites, the `services/{domain}` + Query pattern, the cookie manager, `AuthContext`) · **backend > contract** [`dev/contracts/domains/identity-auth.md`](../../contracts/domains/identity-auth.md) (from > backend-phase-2) · **Unlocks:** every authenticated screen; f2-b3 onboarding & profiles > **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional. --- ## 1. Context — where this sits The client (`client/`, **Next.js 16 App Router + React 19 + MUI v9 + next-intl**, `fa` default & RTL) has a full app shell, the three actor app shells from f0, and a cookie/fetch/Query/auth stack — but **no way to log in**. Today's auth domain is a **username/password stub** (`useLogin({ username, password })` → `/auth/login`) with no UI; per the product docs, Balinyaar's only real credential is **phone-OTP** ([`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md): "Phone number is the primary login credential … Authentication is phone-OTP"). This phase swaps the stub for the real thing and adds the role router that turns "authenticated" into "in the right app". **What already exists (do not rebuild) — confirmed in the codebase:** - **From [`frontend-phase-0`](./frontend-phase-0.md):** the three actor app shells + role-scoped route groups under `(private-routes)`; the **shared OTP code input** and **Iranian phone-number field** composites (`src/components/…` with co-located tests); the **`services/{domain}` + TanStack Query** reference pattern (a `keys.ts` factory, `apis/clientApi.ts` over `clientFetch`, one-hook-per-file, invalidate-on-mutation); the **types-from-contract** convention; the money/format util; the `auth`, `common`, `nav` i18n namespaces seeded in **both** `en.json`/`fa.json`. Read the f0 report ([`reports/frontend-phase-0-report.md`](../../shared-working-context/reports/frontend-phase-0-report.md)) for exactly what shipped and how the mock-`clientApi` seam works. - **The stack (built before f0):** root layout `src/app/[locale]/layout.tsx` (providers NextIntl → Auth → Theme → Query → Notistack); `clientFetch`/`serverFetch` + `ApiError` (`@/lib/api`) — **401 already clears cookies + toasts + redirects without throwing; 403/5xx toast+throw; other 4xx throw-no-toast**; the **cookie manager** (`@/lib/cookies/client` — `setClientCookie`/`deleteClientCookie`, with `AUTH_ACCESS_COOKIE_OPTIONS` ≈15 min and `AUTH_REFRESH_COOKIE_OPTIONS` ≈7 d, both **non-httpOnly** by the current design); `AuthContext` (`useReducer` `[state, dispatch]`, actions `LOG_IN{user?}`/`LOG_OUT`, server-seeded `initialState`); `makeQueryClient` (staleTime 30 s); the **middleware auth gate** (`isTokenAlive` on `access_token`, redirects non-public paths to `/${locale}/login`). - **The auth domain (the stub you are replacing):** `src/services/auth/` — `types.ts` (`LoginDto {username,password}`, `AuthTokens {accessToken,refreshToken}`, `User {id,username}`), `keys.ts` (`authKeys.currentUser()`), `apis/clientApi.ts` (`AuthClientApi.login/logout/getCurrentUser`), `hooks/useLogin|useLogout|useCurrentUser`, `index.ts`. The login hook writes both tokens as non-httpOnly cookies and dispatches `LOG_IN`; `useLogout` deletes both, dispatches `LOG_OUT`, redirects to `/${locale}/login`. **There is no login page yet** — only the hooks. You extend this domain in place. **What you are replacing / removing in this phase:** - The username/password `LoginDto` + `useLogin({username,password})` + `POST /auth/login` path — replaced by the OTP request/verify pair. Remove the dead `username/password` types and the `login` api/hook once the OTP flow is wired; don't leave both (no-unused-vars is a lint **error**). > **Wireframe reality check:** A1/A2 (customer) and B1/B2 (nurse) are the **same OTP mechanism** with > different copy and a different post-login destination. Build **one** OTP flow and parameterise the > intended-role/copy — do not fork two parallel login stacks. ## 2. Required reading (do this first) - [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and [`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md) — how you work, the gate, the §6 contract-gap handoff, the caching/re-render rules. - **The contract you consume:** [`dev/contracts/domains/identity-auth.md`](../../contracts/domains/identity-auth.md) (published by **backend-phase-2**) — the **source of truth** for the OTP/refresh/logout/me shapes, routes, status codes, and the `Me`/roles enum. **Do not guess shapes.** Read it together with [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) (envelope, `snake_case` routes — `[controller]/[action]` so `RequestOtp` → `.../request_otp`; 400/401/403/409 meanings; the locale header) and [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) (enums cross the wire as **stable string codes** — `male`/`female`, roles as codes — and the frontend mirrors them as string-literal unions; **never** hardcode a display label off a code, labels are i18n keys). **If the contract isn't published yet or a needed shape is missing** (e.g. the exact `Me` role codes, the resend-cooldown / max-attempts fields), follow operating-rules §6: append a request to [`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md) and mock behind the `services/auth` seam meanwhile (§4 below). - [`client/CLAUDE.md`](../../../client/CLAUDE.md) — RSC/client boundary, the cookie rules (**auth state only through the cookie manager — never `document.cookie`/`localStorage`**), the services pattern, anti-patterns. - **Invoke the `frontend-designer` skill** before any visual work — A1/A2/B1 are full branded screens (brand mark, teal `#1d4a40` primary CTA, RTL Persian, Vazirmatn/Mikhak). It is the palette/token/typography and `App*`-library contract; all the login UI goes through it. - [`product/wireframes/index.html`](../../../product/wireframes/index.html) — screens **A1, A2, B1, B2** (and the role-router branch implied by A5 home vs B3 nurse status). The exact Persian strings are there: A1 CTA **"دریافت کد تایید"**, nurse-switch link **"پرستار هستید؟ ورود پرستاران ←"**; A2 resend **"ارسال مجدد کد تا ۰۰:۴۵"** + CTA **"تایید و ادامه"**; B1 subtitle **"ویژه پرستاران دارای پروانه نظام پرستاری"**, CTA **"دریافت کد تایید"**; B2 CTA **"تایید و ورود"**. - [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md) — the auth/role rules: **phone is the primary credential** (email never a login key), customer browses with only a verified phone, **sessions are revocable with refresh-token rotation + stolen-token detection**, admin roles are **internal-only / never self-assigned**. - The existing `src/services/auth/*`, `src/context/auth/*`, `src/lib/cookies/*`, `src/lib/api/*`, `src/middleware.ts` — the exact code you extend. ## 3. Scope — build this A vertical slice: **service → hooks → screens → role router**, all over the published contract. ### 3.1 Rewrite `services/auth` for OTP (replace the username/password stub) Keep the f0 service shape (`types.ts` / `keys.ts` / `apis/clientApi.ts` / `hooks/use*.ts` / `index.ts`); swap its contents to OTP. **Mirror the published contract for every shape** — the names below are the intended surface; reconcile field names/casing against `swagger.json` and file a `for-backend.md` request for any gap. - **`types.ts`** — replace `LoginDto` with: - `OtpRequest` — `{ phone: string; intended_role?: RoleCode }` (the role the user is logging in *as*, from the A1/B1 switch — used only for routing/copy; the server decides actual roles). - `OtpVerify` — `{ phone: string; code: string }`. - `Tokens` — `{ accessToken: string; refreshToken: string }` (rename/keep from `AuthTokens`; match wire casing). - `Me` — `{ id: number; phone: string; roles: RoleCode[]; active_role?: RoleCode; profile_completed: boolean; nurse_verification_status?: NurseVerificationStatus }` — the role-router input. Trim/extend to **exactly** what `GET /me` returns in the contract. - `RoleCode` — string-literal union `'customer' | 'nurse' | 'admin'` (codes, not labels). - `NurseVerificationStatus` — string-literal union mirroring the contract's enum (e.g. `'not_started' | 'in_progress' | 'pending_review' | 'verified' | 'rejected'`); used by the router to decide whether a nurse lands on B3 status vs the nurse home. - `OtpRequestResult` — whatever `request_otp` returns (e.g. `{ resend_after_seconds: number; code_length: number; expires_in_seconds: number }`); drive the **resend countdown** and **OTP box count** from this, not from a hardcoded `45`/`4`. - **`keys.ts`** — `authKeys.me()` (replaces `currentUser()`); keep `authKeys.all`. - **`apis/clientApi.ts`** — `AuthClientApi` over `clientFetch`: - `requestOtp(body: OtpRequest): Promise` → `POST .../auth/request_otp` - `verifyOtp(body: OtpVerify): Promise` → `POST .../auth/verify_otp` - `refresh(refreshToken: string): Promise` → `POST .../auth/refresh` - `logout(): Promise` → `POST .../auth/logout` - `getMe(): Promise` → `GET .../me` (Use the **exact snake_case routes** from the contract; the examples follow the `[controller]/[action]` transform.) Remove the old `login` call. - **`hooks/` (one per file):** - `useRequestOtp.ts` — `useMutation` over `requestOtp`. `onSuccess` hands the cooldown/length back to A2. **Do not toast 401/403/5xx** (the fetch layer does) — only surface **domain 4xx** (e.g. 429 rate-limit "try again in N s", invalid-phone 400) as inline field/screen errors. - `useVerifyOtp.ts` — `useMutation` over `verifyOtp`. `onSuccess`: **persist tokens via the cookie manager** (`setClientCookie` with `AUTH_ACCESS_COOKIE_OPTIONS`/`AUTH_REFRESH_COOKIE_OPTIONS`), dispatch **`LOG_IN`**, **`invalidateQueries(authKeys.me())`**, then trigger the role router (3.4). Map the contract's wrong-code / expired-code / max-attempts (lockout) failures to inline states. - `useLogout.ts` — keep behaviour: call `logout`, `deleteClientCookie` both tokens, dispatch `LOG_OUT`, **`invalidateQueries(authKeys.me())`** (or `queryClient.clear()` for auth keys), redirect to `/${locale}/login`. - `useMe.ts` — `useQuery(authKeys.me(), getMe)` (replaces `useCurrentUser`); `enabled` only when authenticated; sensible `staleTime`. This is the role router's data source and the shell's role source. - `useRefresh.ts` (DEFERRED token-rotation UI is out of scope, but) — expose `refresh` as a callable so the **silent refresh** path can rotate the access token when it expires (see 3.5). If the existing fetch layer is to own refresh, document that instead and don't duplicate it; flag the decision in your report. - **`index.ts`** — re-export the new hooks; drop `useLogin`/`useCurrentUser`. ### 3.2 A1 — customer phone login screen The public login page (the route the middleware redirects to: `/${locale}/login`). Per A1 / the frontend-designer skill: - Brand mark + tagline; the **Iranian phone-number field** composite from f0 (placeholder `۰۹۱۲ ۰۰۰ ۰۰۰۰`, RTL-safe, validates an Iranian mobile). - Primary CTA **"دریافت کد تایید"** → `useRequestOtp({ phone, intended_role: 'customer' })`; on success, advance to A2 (carry the masked phone + cooldown + code length). - Secondary link **"پرستار هستید؟ ورود پرستاران ←"** → switches to the **nurse login** variant (B1). - States: **sending** (CTA spinner/disabled), **invalid/non-Iranian number** (inline), **rate-limit hit** ("تا N ثانیه دیگر دوباره تلاش کنید"), **network error** (the fetch layer toasts; keep the field intact). ### 3.3 B1/B2 — nurse login switch (same flow, nurse intent) Not a separate stack — the **same** login screen with `intended_role: 'nurse'`, nurse copy (B1 subtitle **"ویژه پرستاران دارای پروانه نظام پرستاری"**, OTP CTA **"تایید و ورود"**), and a link back to family login. Carry `intended_role` through A1→A2 (B1→B2) so the OTP screen shows the right CTA and the router knows the entry intent. Implement the variant via a query param or a small client switch on the login page — **no second route tree**. ### 3.4 A2 — OTP screen + auto-advance Per A2/B2, using the **shared OTP code input** from f0: - N-digit boxes (N from `OtpRequestResult.code_length`, default 4 per the wireframe), **masked phone echo** ("کد به شماره ۰۹۱۲•••۰۰۰۰ ارسال شد"), **resend countdown** **"ارسال مجدد کد تا ۰۰:۴۵"** driven by `resend_after_seconds` (a single timer, cleaned up on unmount — no leaked intervals). - CTA **"تایید و ادامه"** (customer) / **"تایید و ورود"** (nurse) → `useVerifyOtp`. - States: **auto-advance** (verify fires automatically when the last box is filled), **wrong code** (inline, clear boxes, keep focus), **expired code** (offer resend), **max-attempts lockout** (disable input + show the locked message + when it lifts), **resend** (re-calls `useRequestOtp`, restarts the countdown, disabled until the countdown hits 0). - On verify success the hook stores tokens + dispatches `LOG_IN` + invalidates `/me`; then the **role router** takes over (next). ### 3.5 The role router (after `/me`) A small client component/hook (`useRoleRouter` or a `RoleRouter` boundary placed where the shell decides chrome) that consumes `useMe()` and routes: - **loading** (`/me` in flight) → the f0 `AppLoading` splash; never flash the wrong shell. - **roles includes `nurse`** → the **nurse app** home; if `nurse_verification_status` is not `verified`, land on **B3 verification status** (built in f5) — for now route to the nurse shell's home/placeholder and leave a clear pointer (f5 fills B3). A persistent "verification in progress" banner is **f5's** job; here just route correctly. - **roles includes `customer`** (and not nurse, or `active_role === 'customer'`) → the **customer/family app** home (A5, built in f4 — route to the customer shell's home/placeholder now). - **roles empty / no usable role** → the **SelectRole** screen (3.6). - **`active_role`** (if the contract returns one) wins when a user has multiple roles; otherwise prefer the `intended_role` carried from login, else default customer. - **admin** → the admin shell (f15) — route correctly but don't build admin screens here. Honour the **middleware gate**: it already redirects unauthenticated users to `/login`; the role router runs **after** auth and only decides *which app*. Don't duplicate the auth check in the router. ### 3.6 SelectRole screen (first use) A minimal post-login screen for a brand-new user with **no role**: choose **خانواده (customer)** or **پرستار (nurse)** → call the role-selection mutation (`POST /me/role`, `SelectRoleCommand` per the digest; consume the contract's exact route/body) → `invalidateQueries(authKeys.me())` → re-run the router. Admin is **never** selectable here (admin roles are internal-only). If `intended_role` from login is set, pre-select it. Keep this screen shared/simple; the full onboarding (A3 "who is care for", A4 add-patient, nurse profile bootstrap) is **f2-b3** — link it, don't build it. ### 3.7 Seed `AuthState` with roles Extend the auth context beyond `{ id, username }`: - `src/context/auth/types.ts` — extend `AuthState.currentUser` to carry `{ id; phone; roles: RoleCode[]; active_role?: RoleCode }` (mirror the `Me` essentials the shell needs for chrome) and keep `isAuthenticated`. Keep the reducer's `LOG_IN{user?}`/`LOG_OUT` actions; just widen the `user` payload. - The server-seeded `initialState` (root layout) should seed roles when it can derive them (it reads cookies/`getServerAuthState`); if roles aren't available server-side yet, seed `isAuthenticated` only and let `useMe()` hydrate roles client-side — **don't** put a second source of truth for roles. Document which it is in your report. - The f0 shells already read "a role" to pick chrome — now feed them the real `roles`/`active_role`. ### 3.8 i18n All A1/A2/B1/B2/SelectRole/role-router strings as keys in the **`auth`** namespace (and `common`/`nav` for shared bits) in **both** `messages/en.json` and `messages/fa.json`, in sync, RTL-first. No label is ever derived from a role/status **code** in code — codes map to i18n keys. (DEFERRED) onboarding copy (A3/A4) belongs to f2's `onboarding` namespace. ## 4. Mocks & seams in this phase This phase **introduces no new external-service seam** — OTP/SMS delivery is the **backend's** seam (`ISmsSender`, introduced in backend-phase-2; the frontend never sends SMS). The only frontend "mock" is the standard **contract-gap fallback** from operating-rules §6 and the f0 pattern: - **If [`identity-auth.md`](../../contracts/domains/identity-auth.md) / the b2 swagger snapshot isn't merged when you start**, build a **mock `AuthClientApi`** behind the same `services/auth` seam (the f0 mock-`clientApi` pattern): `requestOtp` returns a fixed `{ resend_after_seconds: 45, code_length: 4, expires_in_seconds: 120 }`; `verifyOtp` accepts a fixed dev code (e.g. `0000`) and returns fake tokens + a `Me` you can toggle (customer / nurse-unverified / no-role) to exercise all three router branches; `getMe` returns the matching `Me`. Selection is by config (env/flag), **never** an `if (mock)` in a hook or screen. - **Record it:** append a row to [`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (seam = `AuthClientApi` mock + file, what's faked, the config key, exact swap steps = point the service at the real `clientApi` once the contract is live) and a follow-up in your phase report. - Any shape the contract doesn't cover (resend/lockout fields, `Me` role codes, `active_role`) → **append a request** to [`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md); do **not** edit backend files. (Reuse the f0 mock-seam mechanism — don't invent a new one.) ## 5. Critical rules you must not get wrong - **Phone is the only credential.** No username/password anywhere after this phase; email is never a login key. Remove the stub `login` path completely (no dead code — it's a lint error). - **No token in `localStorage`, ever.** Tokens live **only** in cookies via the cookie manager (`@/lib/cookies/client`), non-httpOnly per the current design. Never `document.cookie`/`localStorage` for auth. Logout must actually clear both cookies *and* the server session (call `logout`). - **Sessions are revocable; refresh rotates.** Wire `refresh` so an expired access token is silently rotated (or confirm the fetch layer owns this and don't duplicate it). The server does reuse-detection; the client must send the current refresh token and replace **both** tokens on a successful refresh. - **`invalidateQueries(authKeys.me())` on login and logout.** `/me` is the role router's input — a stale `/me` routes the wrong person to the wrong app. Never read roles from two sources. - **Don't toast 401/403/5xx in hooks** — the fetch layer already does. Hooks surface only **domain 4xx** (invalid phone, rate-limit/429, wrong/expired code, lockout) as inline UI states. - **OTP states are explicit, not edges:** sending, resend-cooldown, wrong code, expired code, **max-attempts lockout**, auto-advance. A single timer, cleaned up on unmount (no leaked intervals → no re-render storms). - **The router never flashes the wrong shell.** Show `AppLoading` while `/me` is in flight; only render a shell once the role is known. **admin role is never self-assignable** — SelectRole offers customer/nurse only. - **RSC/client boundary:** login screens, OTP, the router, and the cookie writes are **client** (`'use client'`); no `next/headers`/`@/lib/cookies/server` in them. The middleware already gates routes — don't re-implement the gate in a component. - **Both locales, RTL-first, tokens for colour, MUI v9, MUI primitives stay MUI.** Every string in `en.json` **and** `fa.json`; no label derived from a code. - **Minimise re-renders:** the countdown timer and per-box OTP state stay **low** (in the OTP component), not in a high context; `useMe` subscribers use `select` if they only need `roles`. ## 6. Definition of Done The shared [definition-of-done.md](../_shared/definition-of-done.md), plus: - [ ] `services/auth` is OTP-based: `OtpRequest`/`OtpVerify`/`Tokens`/`Me`(+roles) types, `authKeys.me()`, `requestOtp`/`verifyOtp`/`refresh`/`logout`/`getMe` apis, and the `useRequestOtp`/`useVerifyOtp`/ `useMe`/`useLogout` hooks — the username/password stub is **removed**, not left dangling. - [ ] A1 (customer login), A2 (OTP, auto-advance, resend countdown, lockout), and the **B1/B2 nurse switch** render per the wireframe via the f0 phone/OTP composites, RTL, both locales, branded. - [ ] The **role router** sends customer→family app, nurse→nurse app (B3 status if unverified), no-role→**SelectRole**, admin→admin shell; it shows `AppLoading` during `/me` and never flashes the wrong shell. - [ ] Tokens are stored **only** via the cookie manager; `LOG_IN`/`LOG_OUT` dispatched; `authKeys.me()` invalidated on login **and** logout; refresh wired (or fetch-layer ownership documented). - [ ] `AuthState` carries `roles`/`active_role`; the f0 shells pick chrome from the real role. - [ ] All strings in `auth`/`common`/`nav` in **both** `en.json` and `fa.json`, in sync. - [ ] `npm run check` green; `npm run test:ci` green (you touched/added shared composites and the router — add tests for the OTP state machine and the router branches). - [ ] `client/CLAUDE.md` updated: the OTP login flow, the role-router seam, and the widened `AuthState` noted in *Project Structure* / the auth notes; the now-stale username/password references corrected. ## 7. How to test (what a human can verify after this phase) With the b2 backend running (or the mock seam configured), `npm run dev`: - **Customer happy path:** open `/fa/login` → enter a valid mobile → "دریافت کد تایید" → A2 shows the masked phone + a counting-down resend → enter the code (auto-advance fires verify) → tokens appear in cookies (DevTools → Application → Cookies; **nothing** in localStorage) → redirected to the **customer** app home. `/me` is in the Query cache (React Query Devtools). - **Nurse switch:** from A1 tap "پرستار هستید؟ ورود پرستاران ←" → B1 nurse copy/subtitle → request → verify ("تایید و ورود") → routed to the **nurse** app (B3 status placeholder if unverified). - **No-role user:** verify as a user whose `/me` has empty `roles` → lands on **SelectRole** → choose a role → re-routed into that app. - **OTP edge states:** wrong code → inline error + boxes clear; let the code expire → resend re-enabled by the countdown; trigger max attempts → input locks with a clear message. - **Session:** log out → both cookies cleared, `/me` invalidated, redirected to `/login`; let the access token expire and act → silent refresh rotates it (or you're sent to `/login` cleanly). - **i18n/RTL:** switch to `en` → all auth strings translate, layout stays correct; `fa` is RTL. - `npm run check` and `npm run test:ci` pass (OTP state-machine + router-branch tests included). ## 8. Hand off & document (close the phase) - **Docs:** update [`client/CLAUDE.md`](../../../client/CLAUDE.md) — the phone-OTP flow, the `useRoleRouter` seam, the widened `AuthState` (roles/active_role), and the new auth screens/route; **fix the stale username/password references**. If you add a route group/folder, update *Project Structure* in the same change. No `product/` rule changes expected (you're implementing decided rules); if you discover drift, record it there. - **Contract:** **consume** [`dev/contracts/domains/identity-auth.md`](../../contracts/domains/identity-auth.md) — types come from it, not guesses. Append every gap (resend/lockout fields, exact `Me` role codes, `active_role`, role-selection route/body) to [`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md). Frontend produces **no** contract. - **Handoff & report:** append to [`dev/shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md); write [`reports/frontend-phase-1-report.md`](../../shared-working-context/reports/frontend-phase-1-report.md) (what was built, **what is now testable and exactly how**, what's mocked behind the `AuthClientApi` seam + how f-next swaps it, the contract consumed + gaps filed, follow-ups: B3 banner is f5, A3/A4 onboarding is f2, admin screens are f15). Update [`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) for the `AuthClientApi` mock if you used it. - **Memory:** save a `project` memory note for the phone-OTP decision, the role-router seam + branch logic, and the widened `AuthState`, with a `MEMORY.md` pointer — what a future agent can't cheaply re-derive.