# Backend Phase 2 — Identity: phone-OTP auth, sessions & roles (REST)

> **Mission:** give the marketplace its front door. The server already has a working
> phone-OTP/JWE/passwordless-TOTP engine and a dynamic-permission RBAC system — but it is reachable
> **only over gRPC**, with OTP delivery stubbed to a log line and no session, refresh-rotation, or
> role-selection surface. This phase **wraps that existing machinery in REST**: `/auth/otp/request`,
> `/auth/otp/verify`, `/auth/refresh`, `/auth/logout`, `/me`, `/me/role`; adds revocable
> **`user_sessions`** with refresh-token rotation + stolen-token (reuse) detection; extends `users` with
> the load-bearing identity columns (`gender`, `national_id` NULL-until-KYC, `shahkar_verified_at`); and
> wires the real **`ISmsSender`** seam so an OTP actually leaves the building (mock = log the code). Do
> **not** rebuild auth — reuse `JwtService`, `AppUserManager` OTP, and the RBAC. After this phase every
> authenticated feature in the chain has something to authenticate against.
>
> **Track:** backend · **Depends on:** [b0](./backend-phase-0.md) (REST surface, rate limiter, cross-cutting seams), [b1](./backend-phase-1.md) (config accessor, `notifications`, marketplace migration baseline) · **Unlocks:** every authenticated backend feature; frontend **f1-b2**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.

---

## 1. Context — where this sits

This is **backend phase b2**, the root of the human-actor identity domain and the gate everything else
hangs off — no booking, profile, verification, or payment work can exist without it. The platform has
three actor types — **customer** (payer/family), **nurse** (caregiver/seller), **admin** (back-office) —
and **phone number is the primary login credential** via phone-OTP; email is optional and only required
for admin ([`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)).
KYC is role-staged: a customer registers and browses with a verified phone alone; a nurse must later pass
the full verification pipeline (b6) before becoming bookable. This phase delivers **login + session +
role selection** — it deliberately stops short of profiles, patients, and bank accounts (those are b3).

**What already exists (do not rebuild) — confirmed in the codebase:**
- **The whole auth engine.** [b0](./backend-phase-0.md) kept the working spine: ASP.NET Core Identity,
  **JWE** access tokens (`Jwt/JwtService : IJwtService` — `GenerateAsync(User)`,
  `GenerateByPhoneNumberAsync`, `GetPrincipalFromExpiredToken`, `RefreshToken(Guid)`), **phone-OTP**
  passwordless TOTP (`AppUserManagerImplementation.GenerateOtpCode` / `VerifyUserCode`,
  `GeneratePhoneNumberConfirmationToken`, `ChangePhoneNumber`; provider constants in
  `Identity/Dtos/CustomIdentityConstants`), and the **dynamic-permission RBAC**
  (`DynamicPermissionRequirement`/`Handler`, `DynamicPermissionService.CanAccess`,
  `ConstantPolicies.DynamicPermission`, `IRoleManagerService`). **Wrap these — never reimplement them.**
- **The REST surface & rate limiter.** [b0](./backend-phase-0.md) created the first versioned controllers
  under `Baya.Web.Api/Controllers/V1/` (sealed, `BaseController`, inject `ISender`, `[controller]`/`[action]`
  snake_case tokens, `base.OperationResult(...)`), registered `LoggingBehavior`, and wired `AddRateLimiter`
  + `UseRateLimiter()` with **named per-IP policies** ready for auth/OTP to apply. This phase **applies**
  those policies — it does not define new middleware.
- **Cross-cutting seams.** [b0](./backend-phase-0.md) introduced and DI-registered `IDateTimeProvider`,
  `IFieldEncryptor` (encrypt/decrypt + deterministic `Hash` for lookups), `ICacheService`,
  `IObjectStorage`, and `INotificationDispatcher`. **Reuse `IFieldEncryptor`** for the encrypted `phone`/
  `email`/`national_id` columns and for the `refresh_token_hash`; **reuse `IDateTimeProvider`** for all
  expiry/timestamp math. Do not introduce a second clock or encryptor.
- **Config & the first migration baseline.** [b1](./backend-phase-1.md) created the marketplace migration
  baseline, the typed **cached `platform_configs` accessor**, and the `notifications` write path behind
  `INotificationDispatcher`. Read OTP TTL / session lifetime / max-attempt knobs through the config
  accessor — never hardcode. Your new tables migrate **onto** b1's baseline.
- **`ICurrentUser` + audit interceptor.** [b0](./backend-phase-0.md) wired `ICurrentUser` (Scoped, HTTP-
  context-backed, null-object for jobs/tests) and the SaveChanges interceptor that stamps
  `CreatedAt/ModifiedAt/CreatedById/ModifiedById`. Handlers never set audit fields by hand.
- **Identity tables & their config.** `User : IdentityUser<int>`, `Role`, `UserRole`, `UserRefreshToken`,
  the 8 `IEntityTypeConfiguration`s mapping Identity into the **`usr`** schema, and `Baya.Tests.Setup`
  (in-memory SQLite, full Identity stack incl. passwordless TOTP, real `JwtService`/`AppUserManager`).
  **You extend these in place** — `users`/`roles`/`user_roles` keep their baseline columns and gain new
  ones; `user_sessions` is a new table off `users`.

**What this phase introduces:** the REST auth controllers, the `users` column extensions,
**`user_sessions`** (rotation + reuse detection), the role-selection flow over the existing RBAC, and
**one new seam — `ISmsSender`** (the OTP delivery rail; mock = log the code). The customer national-ID
KYC path, push/social login, and org self-onboarding are **(DEFERRED)** per the product doc — do not
build them.

## 2. Required reading (do this first)

- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
  [`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- **Product — business truth.** [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)
  — phone-as-primary-credential, the customer↔patient split, role-staged KYC (national_id NULL until KYC),
  revocable sessions, MVP-vs-DEFERRED. [`product/overview/platform-summary.md`](../../../product/overview/platform-summary.md)
  — the four ground truths and where identity sits in the actor model.
- **Product — data model.** [`product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md)
  — the exact `users` / `user_sessions` / `roles` / `user_roles` columns and constraints you must mirror
  (`gender` NVARCHAR(10) NULL, `shahkar_verified_at` reset-on-phone-change, `phone` enc UNIQUE,
  `user_sessions.refresh_token_hash`/`is_revoked`/`revoked_at`/`expires_at`, `user_roles.granted_by`/
  `granted_at`/`revoked_at`).
- **Engineering truth.** [`server/CLAUDE.md`](../../../server/CLAUDE.md) — *Identity & auth*, *Persistence*,
  *Startup wiring*, *Project map*; [`server/CONVENTIONS.md`](../../../server/CONVENTIONS.md) — §1 routing,
  §4 controllers, §6 persistence (audit fields, soft-delete, encrypted PII), §11 security (rate limiting),
  §12 service registration.
- **Contract conventions you must honour.** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
  (envelope, status codes, auth header, pagination) and
  [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) (the
  **gender enum is load-bearing** — `male`/`female`, never defaulted/dropped). Section 8 publishes
  `dev/contracts/domains/identity-auth.md` from the [`_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md).
- **Prior handoff & registry.** `dev/shared-working-context/backend/handoff/after-backend-phase-0.md` and
  `after-backend-phase-1.md` (what is live), and
  [`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (the `ISmsSender`
  row you will fill).
- **Code to read and mirror** (do not duplicate): `Baya.Application/Features/Users/**` (the existing
  `UserCreateCommand`, `RefreshUserTokenCommand`, `RequestLogout`, `GenerateUserToken`,
  `TokenRequest/UserTokenRequestQuery` — these already drive OTP/JWE and are your reuse targets; note the
  `//TODO Send Code Via Sms Provider` log line you are replacing), `Baya.Application/Features/Admin/**`
  and `Baya.Application/Features/Role/**` (role/RBAC patterns), `Baya.Infrastructure.Identity/Jwt/JwtService.cs`,
  `Baya.Infrastructure.Identity/AppUserManagerImplementation.cs`, the existing `UserGrpcServices` (shows
  the request→`IMediator`→token shape you are re-exposing over REST), `BaseController`, and
  `WebFramework/Swagger/RequireTokenWithoutAuthorizationAttribute` (mark `/auth/refresh` with it).

## 3. Scope — build this

Vertical slices: **entity/migration → command/query handler → controller endpoint → contract**. All
amounts/IDs follow project types (`User.Id` is `int`; `UserRefreshToken` is `Guid`-keyed). Reuse the
existing OTP/JWE/RBAC plumbing throughout — your handlers orchestrate `IAppUserManager`, `IJwtService`,
and the new session repository; they do not re-derive tokens or hash passwords.

### 3.1 Entity & migration changes

Add one EF migration (onto b1's baseline) covering:

- **Extend `users`** (the `usr` schema Identity table — keep all baseline columns, add via the
  `UserConfig` configuration + entity properties on `User`):
  - `gender` NVARCHAR(10) NULL — `male` / `female`. **Load-bearing** for same-gender matching downstream;
    never defaulted or silently dropped. Not collected at OTP signup; settable later via profile (b3) —
    here it must merely *exist* and round-trip.
  - `national_id` (enc) **NULLABLE** — stays NULL until KYC (b6). Do **not** add it to any signup/verify
    request. `national_id_verified_at` DATETIME2 NULL.
  - `shahkar_verified_at` DATETIME2 NULL — **reset to NULL on phone change** (see §5). b6 sets it.
  - `phone` (enc) — must be **UNIQUE** (one identity per phone). `email` (enc) NULLABLE.
  - `is_active` BIT and `deleted_at` DATETIME2 NULL (soft-delete) with a **global query filter**
    (`deleted_at IS NULL`) — if the baseline `User` lacks these, add them; otherwise confirm they map.
  - Encrypted columns route through `IFieldEncryptor`; the UNIQUE on `phone` uses a deterministic
    `phone_hash` (via `IFieldEncryptor.Hash`) so an encrypted column can still be uniquely indexed —
    mirror the `iban_hash` pattern documented for b3.
- **New `user_sessions`** entity + `IEntityTypeConfiguration` (in a `Persistence/Configuration/UserConfig/`
  sibling, `usr` schema), off `users`:
  - `id` (PK), `user_id` (FK → users), `refresh_token_hash` NVARCHAR (store the **hash** via
    `IFieldEncryptor.Hash`, never the raw token), `device_info` NVARCHAR NULL, `ip_address` NVARCHAR NULL,
    `is_revoked` BIT, `revoked_at` DATETIME2 NULL, `expires_at` DATETIME2 NOT NULL, `created_at`.
  - Index `(user_id, is_revoked)` for fast active-session lookup; index on `refresh_token_hash` for
    rotation lookups.
- **Extend `roles` / `user_roles`** (keep baseline RBAC columns): `user_roles` gains the **grant/revoke
  audit trail** — `granted_by` (FK → users NULL), `granted_at` DATETIME2, `revoked_at` DATETIME2 NULL.
  Ensure the actor roles `customer` / `nurse` and the admin sub-roles exist (seed `customer`/`nurse` if
  the b1 seed did not; admin sub-roles `support`/`finance`/`moderation`/`super_admin` are seeded but only
  ever assigned internally).

Add an `IUserSessionRepository` contract in `Application/Contracts/Persistence/` and expose it on
`IUnitOfWork` (mirror `IUserRefreshTokenRepository`); implement in Persistence.

### 3.2 Commands / queries (`Baya.Application/Features/Identity/` — new area, or extend `Users/`)

Each is a `record` request + `internal sealed` handler; input-bearing commands get a FluentValidation
validator; expected failures return `OperationResult` (never throw). Reads are `AsNoTracking()` +
`.Select()` projections.

- **`RequestOtpCommand(phone)` → `RequestOtpResult`** — normalize/validate the Iranian phone; **upsert**
  the `users` row (create an inactive-until-verified user if new, via the existing `UserCreateCommand`
  path / `IAppUserManager`); call `GenerateOtpCode`; **deliver via `ISmsSender`** (replacing the
  `//TODO Send Code Via Sms Provider` log). Return a non-enumerating result (e.g. `otp_sent: true`,
  `resend_available_in_seconds` from config) — **do not leak whether the phone already existed**.
- **`VerifyOtpCommand(phone, code, device_info?)` → `AuthTokensResult`** — `VerifyUserCode`; on success
  mark `phone_verified_at`/`is_active`, mint a **JWE access token** (`IJwtService.GenerateByPhoneNumberAsync`)
  **and a refresh token**, and **create a `user_sessions` row** storing `refresh_token_hash`,
  `expires_at` (from config), `device_info`, `ip_address` (from `ICurrentUser`/HTTP context). Return
  `{ access_token, refresh_token, access_expires_at, refresh_expires_at, is_new_user, roles[] }`. On a
  fresh user `roles` is empty (drives the f1 role-router to `/me/role`).
- **`RefreshTokenCommand(refresh_token)` → `AuthTokensResult`** — look up the session by
  `Hash(refresh_token)`. **Rotation + reuse detection:** if the session is found and active → mark it
  revoked, mint a new access+refresh pair, create a **new** session (rotation). If the presented token
  hashes to a session that is **already revoked** → treat as **stolen-token reuse**: revoke **all** of
  that user's active sessions (logout-everywhere) and return `401`. Expired session → `401`. Mark the
  controller action with `RequireTokenWithoutAuthorizationAttribute` (it runs without a valid access
  token).
- **`LogoutCommand(refresh_token? | current session)` → `OperationResult`** — revoke the current session
  (`is_revoked=true`, `revoked_at=now`); rotate the security stamp via the existing `RequestLogout` path
  so the JWE `OnTokenValidated` security-stamp check rejects the old access token too. Optional
  `everywhere: true` revokes all the user's sessions.
- **`GetMeQuery()` → `MeResult`** — current user (id, phone-masked, first/last name, gender, `is_active`),
  **roles[]**, **profile-completion** flags (e.g. `has_customer_profile`, `has_nurse_profile` — false for
  now; b3 populates the underlying tables), and **nurse verification status** (read from
  `nurse_verifications.status` when present; until b6 it returns `not_started`/`null` — never read a
  removed `nurse_profiles.verification_status`). Projection only; `AsNoTracking()`.
- **`SelectRoleCommand(role)` → `MeResult`** — assign **`customer` or `nurse`** to the current user via
  `user_roles` (audit `granted_by` = self / system, `granted_at`). **Reject any admin sub-role** with
  `403` — admin roles are internal-only and never self-assignable (§5). Idempotent if the role is already
  held. A user may hold both `customer` and `nurse`.

### 3.3 Controllers (`Baya.Web.Api/Controllers/V1/`)

Sealed, inherit `BaseController`, inject `ISender`, return `base.OperationResult(...)`, snake_case
`[controller]`/`[action]` routes, `CancellationToken` threaded. Apply the b0 rate-limit policies as noted.

| Method & route (snake_case) | Request → handler | Auth | Rate-limited |
| --- | --- | --- | --- |
| `POST api/v1/auth/otp/request` | `RequestOtpCommand` | none | **yes** (per-phone + per-IP OTP policy) |
| `POST api/v1/auth/otp/verify` | `VerifyOtpCommand` | none | **yes** (verify/brute-force policy) |
| `POST api/v1/auth/refresh` | `RefreshTokenCommand` | `RequireTokenWithoutAuthorization` | **yes** (refresh policy) |
| `POST api/v1/auth/logout` | `LogoutCommand` | authenticated | no |
| `GET api/v1/me` | `GetMeQuery` | authenticated | no |
| `POST api/v1/me/role` | `SelectRoleCommand` | authenticated | no |

> Routes render through the snake_case transformer; document the exact emitted paths in the contract from
> the published `swagger.json` (don't assume the token expansion).

### 3.4 Wire `ISmsSender` for OTP delivery

Introduce **`ISmsSender`** (Application contract) with a real-shaped signature
(`Task SendOtpAsync(string phone, string code, CancellationToken ct)` plus a generic
`SendAsync(string phone, string message, ...)` for later transactional SMS). The **mock** Infrastructure
implementation **logs the code** (structured, never the full PII number in plaintext beyond what logging
policy allows) and returns success; selection is by config/registration (`ServiceConfiguration/`
extension), never an `if (mock)` in the handler. `RequestOtpCommand` calls it after `GenerateOtpCode`.

### 3.5 Out of scope (DEFERRED — do not build)

- Customer national-ID KYC (`customer_profiles.national_id_verified_at`) — column deferred; never gate
  browsing on it. → deferred per [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md) §(c).
- Push-notification registration, social login, nursing-company (org) self-onboarding. → same doc.
- Profiles, patients, addresses, nurse bank accounts → **[b3](./backend-phase-3.md)**.
- The `is_verified` flip, Shahkar re-verification on phone change (the *handler* that re-runs Shahkar) →
  **[b6](./backend-phase-6.md)**. This phase only **resets `shahkar_verified_at` to NULL** when the phone
  changes; it does not run Shahkar.

## 4. Mocks & seams in this phase

| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`ISmsSender`** | **introduced here** | logs the OTP code (structured); returns success. Selected by config; real Kavenegar/Ghasedak client swaps in later (implement the same interface, keep idempotency/rate-limit logic). | **add row → 🟡** |
| `IFieldEncryptor` | reuse (b0) | encrypt/decrypt `phone`/`email`/`national_id`, hash `phone`/`refresh_token`. | no change |
| `IDateTimeProvider` | reuse (b0) | clock for OTP/session expiry. | no change |
| `ICacheService` | reuse (b0) | config-accessor cache (b1) only; not new here. | no change |
| `INotificationDispatcher` | reuse (b0/b1) | optional "new login" notification (in-app, b1 write). | no change |

Record `ISmsSender` in [`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md):
seam name + file, what's faked, config keys (gateway api-key, sender line, OTP TTL), step-by-step how to
make it real (pick gateway, add client package to `Directory.Packages.props`, implement `SendOtpAsync`,
register over the mock by config), status 🟡.

## 5. Critical rules you must not get wrong

- **Phone is the primary credential.** OTP is the only public login. **Email is optional** and required
  only for admin — never make email mandatory, never make it a login key.
- **`national_id` is NOT collected at signup** and stays **NULL until KYC** (b6). An unverified
  registration must never look KYC-complete. Do not add `national_id` to any auth request body.
- **`shahkar_verified_at` resets to NULL on phone change.** Whenever the stored phone changes (via
  `ChangePhoneNumber`), null out `shahkar_verified_at` so b6 re-verifies. Don't silently keep a stale
  binding.
- **Sessions are revocable with refresh-token rotation + reuse detection.** Each refresh **rotates**
  (old session revoked, new one issued). A refresh presented against an **already-revoked** session is a
  **stolen-token signal** → revoke all the user's sessions and `401`. Store only the **hash** of the
  refresh token (`IFieldEncryptor.Hash`), never the raw token. Logout must invalidate the session
  server-side (and rotate the security stamp so the access token dies too).
- **Admin roles are NEVER self-assignable** via the public `/me/role`. `SelectRoleCommand` accepts only
  `customer` / `nurse`; any admin sub-role → `403`. Admin provisioning is internal-only and goes through
  the RBAC grant path (audited `granted_by`).
- **OTP is rate-limited and codes expire.** Apply the b0 rate-limit policies to `otp/request`,
  `otp/verify`, and `refresh`. Read TTL/max-attempt/lockout knobs from the b1 config accessor. Treat
  brute-force / lockout as an explicit handled `400`/`429` state — never an unhandled exception. Do not
  enumerate accounts: `otp/request` returns the same shape whether or not the phone existed.
- **`users.gender` is load-bearing** (`male`/`female`) for same-gender matching — the column must exist
  and round-trip cleanly now even though it is populated later; never default it to a value or drop it.
- **Encrypt PII at rest** (`phone`, `email`, `national_id`) via `IFieldEncryptor`; never log plaintext PII
  or project it into non-authorized responses (`/me` masks the phone).
- **Reuse, don't rebuild.** Tokens come from `IJwtService`; OTP from `IAppUserManager`; RBAC from the
  existing permission system. No parallel JWT issuer, no second OTP generator, no `if (mock)` branches.

## 6. Definition of Done

The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The six endpoints exist, are reachable through Swagger, and return the `OperationResult` envelope;
      `otp/request`/`otp/verify`/`refresh` are rate-limited; `refresh` carries
      `RequireTokenWithoutAuthorization`.
- [ ] `users` is extended (`gender`, `national_id` enc NULL, `national_id_verified_at`,
      `shahkar_verified_at`, `phone` enc UNIQUE via hash, `email` enc NULL, `is_active`, `deleted_at`
      + soft-delete query filter); `user_sessions` and the `user_roles` audit columns exist; one clean
      migration onto b1's baseline (`dotnet build` zero new warnings, `dotnet ef migrations` applies).
- [ ] Refresh rotation works and a **replayed/old refresh token is rejected** with all sessions revoked;
      logout invalidates the session **and** the access token (security-stamp rotation).
- [ ] `SelectRoleCommand` assigns `customer`/`nurse` only; an admin sub-role attempt returns `403`.
- [ ] `ISmsSender` is introduced behind DI, the mock logs the code, the registry row is added (🟡), and
      `RequestOtpCommand` calls it (the old TODO log is gone).
- [ ] Handler unit tests (NSubstitute) for verify/refresh/role + at least one `WebApplicationFactory`
      integration test per endpoint area (happy path, 401 on `/me` unauthenticated, 400 on bad OTP,
      reuse-detection 401 on refresh, 403 on admin self-assign). `dotnet test Baya.sln` green.
- [ ] The **Project map** in `server/CLAUDE.md` notes the new `Identity` feature area, `user_sessions`,
      the auth controllers, and the `ISmsSender` seam.

## 7. How to test (what a human can verify after this phase)

A reachable SQL Server is required. Run the API (`dotnet run --project src/API/Baya.Web.Api/...`), open
`/swagger`.

1. **OTP request logs a code.** `POST api/v1/auth/otp/request` with `{ "phone": "09120000000" }` → `200`
   `{ otp_sent: true, ... }`; the **OTP code appears in the server log** (the `ISmsSender` mock). Repeat
   immediately past the limit → `429`.
2. **Verify mints tokens + creates a session.** `POST api/v1/auth/otp/verify` with the phone + the logged
   code → `200` with `access_token`, `refresh_token`, expiries, `is_new_user: true`, `roles: []`. Confirm
   a `user_sessions` row exists (`is_revoked=false`).
3. **`/me` returns the user + roles.** `GET api/v1/me` with `Authorization: Bearer <access_token>` → `200`
   with masked phone, empty `roles`, profile-completion flags false, nurse verification `not_started`/null.
   Without the header → `401`.
4. **Role selection.** `POST api/v1/me/role` `{ "role": "customer" }` → `200`, `/me` now shows
   `["customer"]`. `{ "role": "nurse" }` also succeeds (a user can be both). `{ "role": "super_admin" }`
   → `403`.
5. **Refresh rotates; replay is rejected.** `POST api/v1/auth/refresh` with the refresh token → `200` new
   pair (old session now revoked). Replay the **same old** refresh token → `401`, and confirm **all** that
   user's sessions are revoked (stolen-token detection).
6. **Logout kills the session and the access token.** `POST api/v1/auth/logout` → `200`; the prior
   `access_token` now fails `/me` with `401` (security-stamp rotation), and the session is `is_revoked`.
7. **Bad OTP.** `otp/verify` with a wrong/expired code → `400` with a safe message (no enumeration).

These steps become the "what is now testable" section of your report.

## 8. Hand off & document (close the phase)

- **Docs to update (same change):** `server/CLAUDE.md` — *Project map* (new `Features/Identity` area,
  `user_sessions` table + config, the V1 auth controllers, the `ISmsSender` seam and where it's
  registered) and *Identity & auth* (note OTP delivery is now real-seamed, sessions exist, role-selection
  flow). If you decided any rule not already in the product docs (e.g. the exact reuse-detection response),
  reflect it in [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)
  — record decisions, don't invent rules.
- **Contract to write:** publish **`dev/contracts/domains/identity-auth.md`** from the
  [`_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md) — the six endpoints (routes, request/response
  shapes, auth, rate-limited flags, status codes incl. `401` reuse-detection and `403` admin self-assign),
  the `role` enum (`customer`/`nurse` public; admin sub-roles internal), the `AuthTokensResult` /
  `MeResult` / `RequestOtpResult` shared shapes (mark the masked phone), and the gender enum note. Then
  **publish the `swagger.json` snapshot** per [`openapi/README.md`](../../contracts/openapi/README.md) so
  f1-b2 derives types from it, not by guessing.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-2.md` (auth
  is live over REST; what f1-b2 can now build — login, OTP, role router, `AuthContext` roles; what's
  mocked = SMS delivery), append to `backend/STATUS.md`, write
  `dev/shared-working-context/reports/backend-phase-2-report.md` (what was built, what is testable and
  exactly how — the §7 steps, what is mocked + how to make `ISmsSender` real, the `identity-auth` contract
  produced, follow-ups: profiles/patients/bank in b3, Shahkar/`is_verified` in b6), and update
  [`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (the `ISmsSender`
  row → 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions — how REST wraps the existing
  gRPC/OTP/JWE engine without duplication, the refresh-rotation + reuse-detection design (revoke-all on
  replay), the `phone_hash` UNIQUE-on-encrypted pattern, and the rule that admin roles are never
  self-assignable — with a one-line `MEMORY.md` pointer.
