diff --git a/DEPLOY.md b/DEPLOY.md
index d360f4f..506ae57 100644
--- a/DEPLOY.md
+++ b/DEPLOY.md
@@ -3,6 +3,12 @@
A first, shareable deployment of the whole stack under **balinyaar.ir**, in Docker, behind an existing
Caddy reverse proxy that terminates TLS.
+> **This file is the deploy *procedure*.** The runtime dependency graph — every edge, what breaks when it is
+> down, and where it is configured — is [docs/integration/topology.md](docs/integration/topology.md), and
+> every configuration key on both sides is
+> [docs/integration/config-matrix.md](docs/integration/config-matrix.md). Read those to answer "what talks
+> to what" or "where is this value set"; read this one to actually ship.
+
| Host | Serves | Container |
| --- | --- | --- |
| `balinyaar.ir`, `www.balinyaar.ir` | Next.js web client | `balinyaar-web:3000` |
@@ -28,9 +34,16 @@ machine is now inert and can be deleted. Every value lives in a file in the repo
| Client build-time config (API URL, site origin) | `client/.env.production` |
| Telegram relay config (bot token, chat ids, API key, proxy) | `docker-compose.yml` → `otp-relay.environment` |
+The placeholder string `SET_VIA_USER_SECRETS_OR_ENV` in the base `appsettings.json` names that removed
+store; the *name* is a historical artifact, kept only because it is the sentinel `StartupSecretsGuard` and
+the pre-commit hook both reject. **The mechanism is appsettings files and environment variables** — see
+[docs/integration/config-matrix.md](docs/integration/config-matrix.md), which lists every key, its default,
+and who reads it.
+
The API runs as **`ASPNETCORE_ENVIRONMENT=Development`**, so `appsettings.Development.json` is the file
-that actually loads. An `appsettings.Production.json` would be ignored — put changes in the Development
-file, or change the environment name first.
+that actually loads. There is **no `appsettings.Production.json` in the repo at all**, and adding one would
+be ignored until the environment name changes too — put changes in the Development file, or change the
+environment name first.
The relay's shared secret appears twice and the two must match: `Seams:Sms:Telegram:ApiKey` in the
appsettings file and `API_KEY` in the compose file. It was rotated away from the value in
@@ -64,7 +77,7 @@ people you trust, and none of which are acceptable once strangers can reach the
### Going to Production later
1. Set `ASPNETCORE_ENVIRONMENT: Production` in `docker-compose.yml`.
-2. Create `appsettings.Production.json` with the same content as the Development file, but with **real**
+2. **Create** `appsettings.Production.json` (it does not exist) with the same content as the Development file, but with **real**
`IdentitySettings:SecretKey` / `Encryptkey` — `StartupSecretsGuard` rejects anything containing
`not-for-production` outside Development, so the current dev keys will refuse to boot (by design).
Keep `Seams:FieldEncryption` byte-identical.
diff --git a/dev/contracts/README.md b/dev/contracts/README.md
index 7528c4f..4132af3 100644
--- a/dev/contracts/README.md
+++ b/dev/contracts/README.md
@@ -1,4 +1,29 @@
-# Contracts — the shared interface between `client/` and `server/`
+# Contracts — MOVED to `docs/integration/`
+
+> ## ⛔ This folder is history. Do not read it as the contract.
+>
+> **The live client↔server contract is [`docs/integration/`](../../docs/integration/index.md).** Start at
+> its [index.md](../../docs/integration/index.md) — the whole seam on one page — then open the one
+> [domain file](../../docs/integration/domains/index.md) you need.
+>
+> | Was here | Now |
+> | --- | --- |
+> | `conventions/api-conventions.md` + `conventions/money-and-types.md` | [`docs/integration/api-contract.md`](../../docs/integration/api-contract.md) |
+> | `domains/*.md` (17 files, named after backend phases) | [`docs/integration/domains/`](../../docs/integration/domains/index.md) (22 files, named after the client's `services/` domains) |
+> | `openapi/swagger.v1.json` (2026-07-13) | [`docs/integration/openapi/`](../../docs/integration/openapi/README.md) (refreshed 2026-07-29) |
+>
+> Everything here was **frozen 2026-07-13** and has been superseded. It was audited against the live
+> swagger during phase 2: the route-level content held up (no route named here is missing from the live
+> API), but the body-casing rule, the server's local URL scheme, the envelope's field count and the enum
+> vocabularies had all drifted. What changed and why:
+> [domains/index.md § What replaced what](../../docs/integration/domains/index.md#what-replaced-what).
+>
+> Phase 6 archives this folder. Until then it stays readable **as a record**, not as an instruction.
+
+---
+
+
+The original README, kept for the record
The two projects are independent (no shared build). This folder is their **single shared source of
truth** for everything that crosses the wire: API routes, request/response shapes, status codes, enums,
@@ -43,3 +68,5 @@ shared flows, and money/format conventions. It lets a frontend agent build again
> Keep contracts **versioned by being honest**: when a shipped shape changes, update its `domains/*` doc
> and the OpenAPI snapshot in the same change, and call it out in the handoff so the frontend re-syncs.
+
+
diff --git a/dev/contracts/domains/messaging.md b/dev/contracts/domains/messaging.md
index 7c7709e..410a0bf 100644
--- a/dev/contracts/domains/messaging.md
+++ b/dev/contracts/domains/messaging.md
@@ -1,3 +1,20 @@
+# messaging — MERGED into `docs/integration/domains/`
+
+> ⛔ **This headerless 851-byte fragment silently amended
+> [`messaging-notifications-admin.md`](messaging-notifications-admin.md) next to it** — two live files
+> describing one domain, which is contradiction **C-8**.
+>
+> Both are superseded. Phase 2 split the merged content three ways, along the client's actual domain
+> boundaries:
+>
+> | Content | Now |
+> | --- | --- |
+> | Tickets, threads, messages, `is_internal`, the staff queue | [`docs/integration/domains/tickets.md`](../../../docs/integration/domains/tickets.md) |
+> | The notification feed and unread badge | [`docs/integration/domains/notifications.md`](../../../docs/integration/domains/notifications.md) |
+> | Platform config, holidays, audit, support alerts | [`docs/integration/domains/admin.md`](../../../docs/integration/domains/admin.md) |
+>
+> Every REQ-028 amendment below is folded into `tickets.md` as current fact, not as a change log. Phase 6
+> archives this folder; until then, read it as a record.
---
diff --git a/dev/contracts/openapi/README.md b/dev/contracts/openapi/README.md
index 75351aa..d2aeb80 100644
--- a/dev/contracts/openapi/README.md
+++ b/dev/contracts/openapi/README.md
@@ -1,4 +1,16 @@
-# OpenAPI snapshots
+# OpenAPI snapshots — MOVED to `docs/integration/openapi/`
+
+> ⛔ **The `swagger.v1.json` in this folder is the 2026-07-13 snapshot and is stale.** The current one is
+> [`docs/integration/openapi/`](../../../docs/integration/openapi/README.md) (2026-07-29, 178 paths / 186
+> operations), with its provenance and regeneration steps recorded there.
+>
+> The claim below that the server "publishes documents `v1`, `v1.1`" is literally true and substantively
+> empty: both documents are registered, but all 55 controllers are `[ApiVersion("1")]`, so the `v1.1`
+> document contains **zero paths** (contradiction **C-9**, resolved in phase 2).
+>
+> Phase 6 archives this folder. Until then, read it as a record.
+
+---
The server already generates OpenAPI via **NSwag** (Swagger UI at `/swagger`, documents `v1`, `v1.1`).
This folder holds the **published `swagger.json` snapshot(s)** so the frontend can generate/verify types
diff --git a/docs/README.md b/docs/README.md
index 3c82df2..4876d4c 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -28,7 +28,7 @@ The entry point. Start here, follow one link, stop reading.
| Section | What it holds | Status |
| --- | --- | --- |
| [rules/](rules/index.md) | What must never be broken — the tiered rule set behind the `CLAUDE.md` files | **written** · phase 1 |
-| [integration/](integration/index.md) | The client↔server seam in one place: contract, config, topology, OpenAPI | OpenAPI snapshot only · phase 2 |
+| [integration/](integration/index.md) | The client↔server seam in one place: contract, config, topology, OpenAPI | **written** · phase 2 |
| [flows/](flows/index.md) | What is implemented, and how to test it — one file per user journey | not yet written · phase 3 |
| [status/](status/index.md) | Where the project actually is: implemented, backlog, decisions | not yet written · phase 4 |
| [roadmap/](roadmap/index.md) | Where it goes next, and what gates a launch | not yet written · phase 5 |
diff --git a/docs/_plan/README.md b/docs/_plan/README.md
index ee29512..c69c02c 100644
--- a/docs/_plan/README.md
+++ b/docs/_plan/README.md
@@ -153,8 +153,8 @@ stays small.
| Phase | Status | Finished | Notes |
| --- | --- | --- | --- |
| 0 Inventory & scaffold | **done** | 2026-07-29 | 275 files surveyed (not ~260). OpenAPI refreshed — drift is **1 endpoint**, not a rewrite. 3 of 5 seeded contradictions were narrower than stated; see [open-contradictions.md](open-contradictions.md#corrections-to-the-seeded-list). Phase 4 owns 149 files |
-| 1 Rules consolidation | not started | | |
-| 2 Integration & dependency | not started | | |
+| 1 Rules consolidation | **done** | 2026-07-30 | Row reconstructed by phase 2 — phase 1 finished without updating it. Evidence: all 18 `docs/rules/**` files exist and are stamped `2026-07-30 against d3ec723`, `docs/README.md` marks the section **written**, and C-11 + R-1…R-6 are recorded as resolved-by-phase-1 |
+| 2 Integration & dependency | **done** | 2026-07-30 | 27 files under `docs/integration/`. All 186 operations assigned to exactly one of 22 domain files (1:1 with `client/src/services/`). **Main finding: 24 phantom endpoints** — the client↔swagger axis nobody had checked; 2 are live 404s. Also 5 mis-filed REQs, 1 never-filed REQ (**C-15**), 8 client drifts, 5 config gaps. Resolved C-2, C-6, C-7, C-8, C-9 + C-3's contract half |
| 3 Flow atlas | not started | | |
| 4 Backlog reconciliation | not started | | |
| 5 Roadmap | not started | | |
diff --git a/docs/_plan/open-contradictions.md b/docs/_plan/open-contradictions.md
index 29cc89a..234ac25 100644
--- a/docs/_plan/open-contradictions.md
+++ b/docs/_plan/open-contradictions.md
@@ -2,6 +2,8 @@
> Opened 2026-07-29 by [phase 0](phase-0-inventory-and-scaffold.md), against commit `c99e3f4`.
> **Phase 0 only finds these. It resolves none of them.**
+> Last updated 2026-07-30 by [phase 2](phase-2-integration.md), against commit `d3ec723`:
+> **C-2, C-6, C-7, C-8 and C-9 resolved**, C-3's contract-layer half resolved, **C-15 and C-16 added**.
Each row is *claim A* vs *claim B* with a location for both, and the phase that owns the resolution.
When a phase resolves one, it moves the row to **Resolved** at the bottom with the decision, and — per
@@ -17,18 +19,15 @@ written up as they actually are, with the evidence, in [§ Corrections to the se
| # | Claim A | Claim B | Owner | Status |
| --- | --- | --- | --- | --- |
| C-1 | **Set the crypto keys with `dotnet user-secrets`** — [manual-testing-plan.md:22](../../dev/post-phase/manual-testing-plan.md), [:233](../../dev/post-phase/manual-testing-plan.md) | **`user-secrets` is not used; `` was removed so the store is not read** — [CLAUDE.md:90](../../CLAUDE.md), [DEPLOY.md:20](../../DEPLOY.md), [server/CLAUDE.md:70](../../server/CLAUDE.md), and `Baya.Web.Api.csproj` (no `UserSecretsId` element) | 3 | open |
-| C-2 | **Placeholder value is literally `SET_VIA_USER_SECRETS_OR_ENV`** — `server/src/API/Baya.Web.Api/appsettings.json` (6 occurrences), enforced by `StartupSecretsGuard` and by the pre-commit hook | **That store does not exist any more** (C-1's B side). The name instructs a reader to use a removed mechanism | 2 | open — naming only, behaviour is correct |
-| C-3 | **The server listens on `https://localhost:5002`** — [CLAUDE.md:62](../../CLAUDE.md), [:135](../../CLAUDE.md), [api-conventions.md:6](../../dev/contracts/conventions/api-conventions.md), [RUNBOOK.md:7](../../dev/post-phase/refinement/RUNBOOK.md), [:84](../../dev/post-phase/refinement/RUNBOOK.md), [:112](../../dev/post-phase/refinement/RUNBOOK.md), + 12 more | **It listens on `http://localhost:5002`** — `launchSettings.json:25` (`"applicationUrl": "http://localhost:5002"`), and `client/.env.development:20` (`NEXT_PUBLIC_API_URL = http://localhost:5002`) | 2 + 3 | **partly resolved by phase 1** — the three rule-file occurrences (root `CLAUDE.md` ×2, `server/CLAUDE.md`) now say `http`. `api-conventions.md` (phase 2) and `RUNBOOK.md` + the remaining 12 (phase 3) are untouched |
+| C-3 | **The server listens on `https://localhost:5002`** — [CLAUDE.md:62](../../CLAUDE.md), [:135](../../CLAUDE.md), [api-conventions.md:6](../../dev/contracts/conventions/api-conventions.md), [RUNBOOK.md:7](../../dev/post-phase/refinement/RUNBOOK.md), [:84](../../dev/post-phase/refinement/RUNBOOK.md), [:112](../../dev/post-phase/refinement/RUNBOOK.md), + 12 more | **It listens on `http://localhost:5002`** — `launchSettings.json:25` (`"applicationUrl": "http://localhost:5002"`), and `client/.env.development:20` (`NEXT_PUBLIC_API_URL = http://localhost:5002`) | 2 + 3 | **partly resolved by phases 1 + 2** — the three rule-file occurrences now say `http`; phase 2's [api-contract.md](../integration/api-contract.md) states `http` and banner-marks `api-conventions.md` as history. **Phase 2 also found a new occurrence the survey missed: `client/.env.sample:20`**, the file a fresh clone copies. `RUNBOOK.md` + the remaining occurrences are phase 3 |
| C-4 | **RUNBOOK's `dotnet dev-certs https --trust` step is required**, because the browser would otherwise reject the API — [RUNBOOK.md:26](../../dev/post-phase/refinement/RUNBOOK.md) | **The API is plain HTTP locally** (C-3's B side), so there is no certificate to trust | 3 | open — likely a dead step |
| C-5 | **Bring-up starts a local SQL Server in Docker on `localhost:1433`** — [RUNBOOK.md:18](../../dev/post-phase/refinement/RUNBOOK.md), [:35–48](../../dev/post-phase/refinement/RUNBOOK.md), [:63](../../dev/post-phase/refinement/RUNBOOK.md) | **The committed dev config points at a remote SQL Server** — `appsettings.Development.json` (`Server=87.107.152.16,1433`), and [manual-testing-plan.md:20](../../dev/post-phase/manual-testing-plan.md) calls the remote one "currently" the target | 3 | open — the two bring-up paths give *different worlds*: the remote DB is already seeded, a fresh local one is not |
-| C-6 | **`GET /api/v1/webhooks/payouts/{provider}` does not exist** — absent from `dev/contracts/openapi/swagger.v1.json` (frozen 2026-07-13) and from every `dev/contracts/domains/*.md` | **It exists** — present in the fresh snapshot `docs/integration/openapi/swagger.v1.json` (2026-07-29). See [§ OpenAPI drift](#openapi-drift) | 2 | open |
-| C-7 | **`GeneratePayoutBatchCommand`'s shape** per the 07-13 snapshot | **A different shape** in the 07-29 snapshot — the only schema of 339 whose definition changed | 2 | open |
-| C-8 | **Two contract files describe the same domain** — [`dev/contracts/domains/messaging.md`](../../dev/contracts/domains/messaging.md) is a headerless 851-byte fragment ("Refinement phase 3 additions (REQ-028)") sitting beside the 10.8 K [`messaging-notifications-admin.md`](../../dev/contracts/domains/messaging-notifications-admin.md), which it silently amends | — | 2 | open — merge, don't move both |
-| C-9 | **The OpenAPI folder publishes documents `v1` *and* `v1.1`** — [openapi/README.md:3](../../dev/contracts/openapi/README.md) | **Only `swagger.v1.json` has ever been committed**; `v1.1` was not fetched during this survey | 2 | open — `UNVERIFIED`, check `/swagger/v1.1/swagger.json` when the server is next up |
| C-10 | **18 hardening items are open** — [issues.md](../../dev/post-phase/hardening/issues.md), 18 of 18 checkboxes unticked, last touched 2026-07-17 | **Fourteen UI phases, two manual-testing iterations, a Telegram integration and a deploy commit ran afterwards** (`12ce7fa` → `96b57eb`, 07-20 → 07-28) without ticking any box | 4 | open — Phase 4 must re-verify each item against code, not trust the checkbox |
| C-12 | **The skill is current** | **It is exactly one iteration behind the code.** SKILL.md's last commit is `baa3cc6` ("manual improvement 1"); `client/CLAUDE.md`'s is `e6a8f93` ("manual improvement 2"), which changed **44 files, +3419/−2449** under `client/src`. Anything iteration 2 changed is absent from the skill | 7 | **partly resolved by phase 1** — the design-language half is corrected (see R-2); phase 7 still owns the skill's own workflow/procedure content |
| C-13 | **`dev/` is "the plan for building Balinyaar"**, written in the imperative — [dev/README.md:3](../../dev/README.md) | **It is a record of work already done.** `dev/phases/` last touched 2026-06-28; the code it describes shipped weeks ago | 6 | open — resolved by the archive banner, not by editing 199 files |
| C-14 | **`temp/swagger.json` is a stale committed duplicate** — [_plan/README.md](README.md) diagnosis table | **It is not committed at all** — `.gitignore:1` ignores `temp`, and `git ls-files temp/` is empty. It is local clutter, not repo content | 0 | see [§ Corrections](#corrections-to-the-seeded-list) |
+| C-15 | **`ui-phase-11-report.md:235` records "REQ-061…064 appended"** to the frontend request ledger | **REQ-061 was never appended.** The ledger goes 060 → 062; **ten live client files** cite REQ-061 for the admin user directory (`admin_users/search`, `admin_users/lookup`), which are 2 of the 24 phantom endpoints | 4 | **found by phase 2** — phase 4 must *file* it, not assume it is tracked |
+| C-16 | **Five REQs' filed status** — REQ-050, REQ-063, REQ-066, REQ-067 (open, as filed) and the reasons `admin/constants.ts` gives for the admin mock | **All five are wrong or narrower than filed**, checked against the live swagger rather than against another document. `variantLabel` already ships; `tickets/close`+`reopen` already ship; `search/nurses` and `nurses/{id}/profile` are already anonymous; REQ-029/030 already shipped | 4 | **found by phase 2** — the table is in [phase-2's handoff § B](phase-2-integration.md) |
---
@@ -52,14 +51,27 @@ at `dev/contracts/openapi/swagger.v1.json`:
**Schema definitions changed (1)**
-- `GeneratePayoutBatchCommand`
+- `GeneratePayoutBatchCommand` — gained `systemInitiated: boolean` (**C-7**, resolved)
-> **This is Phase 2's worklist for the machine contract, and it is two lines long.** The 16-day gap
-> between the snapshot and the code produced almost no wire-level drift. Phase 2 should therefore spend
-> its budget on the **hand-written** `dev/contracts/domains/*.md` — 17 files, frozen the same day, never
-> mechanically checked against anything — rather than on the JSON. See
+> **This was Phase 2's worklist for the machine contract, and it was two lines long.** The 16-day gap
+> between the snapshot and the code produced almost no wire-level drift. Phase 2 therefore spent its
+> budget on the **hand-written** contract docs instead. See
> [§ Corrections](#corrections-to-the-seeded-list) C-14b.
+### What phase 2 found once it looked
+
+Re-scoping was right, but the conclusion "the hand-written docs are where the drift is" turned out to be
+only half true, and the more useful axis was a third one nobody had checked:
+
+| Axis | Result |
+| --- | --- |
+| Old contract docs → live swagger, at **route** level | **Clean.** Every route named across the 17 files exists in the live API (the two apparent misses are prose counter-examples: "X, *not* Y") |
+| Old contract docs → live swagger, at **convention** level | **Drifted.** Body casing ("typically snake_case" vs. proven camelCase), the server's local URL scheme, the envelope's field count (5 vs. 6 — `code` was added), and the enum vocabularies |
+| **Client's real API clients → live swagger** | **24 phantom routes** — calls to endpoints the server does not expose, 2 of them reachable today. This axis had never been checked and is where the real gap was |
+
+The third row is the phase's main finding, and it is in
+[phase-2's handoff § A](phase-2-integration.md).
+
---
## Corrections to the seeded list
@@ -105,6 +117,11 @@ when phase 4 creates it** — that file does not exist yet, so this table is the
| # | Was | Decision | By |
| --- | --- | --- | --- |
| **C-11** | The frontend-designer skill and `client/CLAUDE.md` both claimed the design language, with no stated precedence | **Precedence is now stated in both directions.** The skill is the **design** contract (brand, tone, logo construction, the visual decisions, and the workflow for turning a design into a screen); [`docs/rules/client/`](../rules/client/) is the **engineering** contract and **wins on every overlap** — tokens, typography, the component library, shells, icons. SKILL.md's header carries the precedence statement plus a table pointing at the four files it defers to, and the overlapping detail was removed from the skill rather than duplicated. `client/CLAUDE.md` no longer restates design content at all. | phase 1 |
+| **C-2** | The placeholder `SET_VIA_USER_SECRETS_OR_ENV` names a store that was removed, so its *name* instructs a reader to use a removed mechanism | **The name stays; the mechanism is documented authoritatively elsewhere.** The string is a load-bearing sentinel in **seven live files** — `appsettings.json` (×6), `StartupSecretsGuard.PlaceholderMarkers`, `.githooks/pre-commit`, `.githooks/README.md`, `Baya.Test.Api/StartupSecretsGuardTests.cs` (×2), `docs/rules/shared/git-and-gates.md`, `docs/rules/server/structure.md`. Renaming it is a server-code + git-hook + test change needing `dotnet build`/`dotnet test` to prove the gate still fires — outside a documentation phase's scope. [config-matrix.md](../integration/config-matrix.md#the-placeholders-name) is now the single authoritative statement that **config lives in appsettings files and environment variables and `user-secrets` is not read**, and `DEPLOY.md` says the same. **The rename is filed for phase 4 with that exact seven-file worklist.** | phase 2 |
+| **C-6** | `POST /api/v1/webhooks/payouts/{provider}` was absent from the frozen contract | **It exists and is now documented** — the payout transferor's reconciliation callback, anonymous, `webhook` rate-limit policy, in [domains/payouts.md](../integration/domains/payouts.md). | phase 2 |
+| **C-7** | `GeneratePayoutBatchCommand` changed shape between the two snapshots, unexplained | **It gained `systemInitiated: boolean`** alongside `periodStart`/`periodEnd`. That is the refinement-phase-7 scheduler flag distinguishing a job-generated draft batch from an admin-generated one — which is what keeps the "generation may be automatic, *processing* is always an explicit admin action" rule auditable. Recorded in [domains/payouts.md](../integration/domains/payouts.md). | phase 2 |
+| **C-8** | Two live contract files for one domain: a headerless 851-byte `messaging.md` silently amending the 10.6 K `messaging-notifications-admin.md` | **Merged and split three ways along the client's real domain boundaries** — tickets → [domains/tickets.md](../integration/domains/tickets.md), the notification feed → [domains/notifications.md](../integration/domains/notifications.md), config/holidays/audit/alerts → [domains/admin.md](../integration/domains/admin.md). Every REQ-028 amendment is folded in as current fact, not as a change log. Both old files carry a merged-into banner so neither reads as live; phase 6 archives them. | phase 2 |
+| **C-9** | The OpenAPI folder claimed documents `v1` **and** `v1.1`; only `v1` was ever committed | **Both documents genuinely are registered** (`AddSwagger("v1","v1.1")` in `Program.cs`), so `/swagger/v1.1/swagger.json` is served — **and it contains zero paths.** `ApiVersionDocumentProcessor` drops every path whose URL lacks the document's version segment, and all **55 controllers are `[ApiVersion("1")]`** on the route template `api/v{version:apiVersion}/…`. The old claim was literally true and substantively empty. `v1` is the contract; only `v1` is worth committing. **Resolved by reading the code — no server boot needed, so the `UNVERIFIED` marker is retired.** | phase 2 |
### Corrections landed by phase 1 that were not on the seeded list
diff --git a/docs/_plan/phase-2-integration.md b/docs/_plan/phase-2-integration.md
index 021f659..d5ce6da 100644
--- a/docs/_plan/phase-2-integration.md
+++ b/docs/_plan/phase-2-integration.md
@@ -145,5 +145,136 @@ file, and a deploy question is answered by `topology.md` + `config-matrix.md` wi
## Handoff
-_(filled in by the agent that runs this phase — especially: the drift list, since Phase 4 turns the
-`phantom` and `drifted` rows into backlog items)_
+> Run 2026-07-30 against commit `d3ec723`. **All verification items pass**, checked mechanically
+> (script in the session scratchpad, results reproduced below).
+
+### What shipped
+
+```
+docs/integration/
+ index.md 153 lines — the whole seam, one screen and a half
+ api-contract.md envelope · casing · status codes · auth · pagination · idempotency · money · enums · rate limits
+ config-matrix.md every key in both appsettings, docker-compose, 3 .env files, the bot, Caddy, CORS
+ topology.md mermaid graph + a 10-edge table + ports + startup order + the local variant
+ domains/
+ index.md the census, the route-shape exceptions, the enum map, what-replaced-what
+ <22 files> one per client services/ domain, 1:1
+ openapi/README.md updated: C-9 resolved, the servers-block provenance wrinkle recorded
+```
+
+Also updated: `DEPLOY.md` (links topology + config-matrix; states there is no `appsettings.Production.json`
+so step 2 of "Going to Production" is a *create*), `docs/README.md` (integration → **written**),
+`dev/contracts/README.md` + `openapi/README.md` + `domains/messaging.md` (moved/merged banners).
+
+### Verification
+
+| Item | Result |
+| --- | --- |
+| Every swagger path declared in exactly one `domains/*.md` | **178/178**, 0 unreferenced, 0 duplicated |
+| Operation census adds up | 186 = 184 domain + 2 `ping` in `api-contract.md` |
+| Every phantom declared with a reason + REQ | **24/24** |
+| `config-matrix.md` accounts for every key | Diffed mechanically; 5 gaps found and written up |
+| `DEPLOY.md` no longer instructs `user-secrets` | Pass — it already documented the removal; now also links the new files |
+| `index.md` fits a screen and a half | 153 lines |
+| Every doc carries a `Last verified` stamp | 27/27 |
+| No reference file over 400 lines | Pass |
+| Relative links resolve | **216 checked, 0 broken** |
+
+### Scope note
+
+`api-contract.md` was **merged into `index.md`'s siblings as planned**, but the plan's `domains/` count of
+22 was met by re-cutting the file set along the **client's** domain boundaries rather than the backend
+phases the old 17 files used. That is what makes the mapping 1:1 and is why the file names changed.
+
+`swagger.v1.json` was **not** re-fetched — Phase 0's 2026-07-29 snapshot is 1 day older than this run and
+the server was not booted. Everything here is derived from that snapshot plus current source.
+
+---
+
+### Drift list — Phase 4's input
+
+**A. Phantom endpoints (24).** The client's real API clients call 24 routes the server does not expose.
+Each is declared in its domain file with its REQ. Grouped by REQ:
+
+| REQ | Status | Phantoms | Domain |
+| --- | --- | --- | --- |
+| REQ-031 | deferred | 3 (`admin_roles/*`) | admin |
+| **REQ-061** | **never filed** | 2 (`admin_users/search`, `/lookup`) | admin |
+| REQ-022 | partial | 3 (`checkout_bnpl/options`, `/schedule`, `/wallet_installments`) | bnpl |
+| REQ-032 | partial | 5 (`centers/me/*`, `partner-centers/{id}/nurses`) | partner-center |
+| REQ-033 | partial | 1 (`centers/me/settlement`) | partner-center |
+| REQ-064 | open | 1 (`centers/me/bookings/{id}`) | partner-center |
+| REQ-034 | deferred | 3 (`admin_verifications` documents-url / approve / reject) | verification |
+| REQ-035 | deferred | 3 (`admin_refunds` preview / approve / reject) | refunds |
+| REQ-036 | deferred | 1 (`admin_payouts/{id}/transfer_reference`) | payouts |
+| REQ-047 | open | 1 (`bookings/payment_history`) | payment |
+| REQ-048 | open | 1 (`refunds/my`) | refunds |
+| REQ-063 | open | 1 (`tickets/{id}/assign`) | tickets |
+
+**Two are live 404s** because their domain's mock is off: `bookings/payment_history` (payment) and
+`tickets/{id}/assign` (tickets). Both are guarded in the client, so neither is user-visible — but they are
+reachable, not hypothetical.
+
+**B. Five REQs whose filed status is wrong or narrower than reality.** Each was checked against the
+swagger, not against another document. Phase 4 should re-triage these rather than copy the ledger.
+
+| REQ | Filed as | Actually |
+| --- | --- | --- |
+| REQ-050 | open — "the list DTO carries neither field" | `BookingRequestListItemDto` **has `variantLabel`** (and `patientAge`). It lacks `variantPrice`/`variantPriceUnit` and the `status=answered` group filter. The REQ was verified against the *client type*, not the wire |
+| REQ-063 | open — "no close/reopen/assign routes yet" | `tickets/{id}/close` and `/reopen` **exist and are wired**. Only `assign` is missing. `tickets/constants.ts` carries the stale reason |
+| REQ-066 | open — needs an anonymous search read | `GET /search/nurses` **is already anonymous**. What is missing is the rate limit — `SearchController` has no `[EnableRateLimiting]` |
+| REQ-067 | open — needs an anonymous profile read | `GET /nurses/{id}/profile` **is already anonymous**. What is missing is the privacy review |
+| REQ-029 / REQ-030 | delivered | Correct — but `admin/constants.ts` still names them as reasons the admin mock is primary. The only live reasons are REQ-031 and REQ-061 |
+
+**C. Client-side drifts (client work, not contract work).**
+
+| # | Where | What |
+| --- | --- | --- |
+| 1 | `client/src/lib/api/types.ts` | `ApiEnvelope` omits **`code`**, which the server sends and `clientFetch` already reads at runtime. Incomplete type, no defect |
+| 2 | `client/src/services/admin/apis/clientApi.ts` | `pageQuery()` sends **`page_size`**; these endpoints declare `PageSize`. Model binding is case-insensitive, **not separator-insensitive**, so it binds nothing and every admin list silently gets the default page size. Latent until `USE_ADMIN_MOCK` flips |
+| 3 | `client/src/services/bookingRequests/types.ts` | Marks `variantLabel` as client-augmented/absent; the server serves it. Widening the type would light up the redesigned inbox card today |
+| 4 | `client/src/services/payouts/apis/clientApi.ts` | Comment says `NursePayoutHistoryDto` has no `failureReason`; **the wire has it** on all three payout read models |
+| 5 | `client/src/services/tickets/constants.ts` | Says the backend has no close/reopen/assign; close and reopen exist (B, above) |
+| 6 | `client/src/services/payouts/apis/clientApi.ts` | Sends `Idempotency-Key` on `admin_payouts` process/retry, where the server never reads it. Decorative, not harmful |
+| 7 | `client/.env.sample` | Still `NEXT_PUBLIC_API_URL = https://localhost:5002` — the `https` half of **C-3**, in the file a fresh clone copies. `.env.development` is correct |
+| 8 | `client/src/services/profiles/` | `POST customer_profiles/avatar` exists and is live; the client only wires the nurse route, so a customer cannot set a photo |
+
+**D. Cross-side vocabulary asymmetry (1).** The client's `TicketAuthorRole` includes **`system`**;
+`Entities/Messaging/TicketCodes.cs` defines only `customer`/`nurse`/`admin`. Safe on the reading side, but a
+reader of the client types would wrongly conclude the server emits it. All other 17 shared vocabularies
+match exactly, both directions.
+
+**E. Route-shape exceptions (4).** `admin/partner-centers` (+ `/set-active`, `/sponsor-nurse`),
+`admin/tickets`, `admin/reviews/…` and `internal/bookings/…` hardcode route strings instead of using the
+`[controller]`/`[action]` tokens; three introduce hyphens, against the snake_case rule. **Because the route
+also derives the dynamic-permission key, normalising them breaks permissions as well as URLs** — recorded,
+not fixed.
+
+**F. Config gaps (5).** Written up in
+[config-matrix.md § What the diff found](../integration/config-matrix.md#what-the-diff-found):
+`NEXT_PUBLIC_EVV_MOCK_GPS` and `NEXT_PUBLIC_VERSION` read but declared in no `.env`;
+`OpenTelemetry:Otlp:Endpoint`, `Search:Backend` and `Seed:Admin*` read but set nowhere (and a bad
+`Search:Backend` **throws at startup**); `.env.sample`'s stale `https`; the Telegram shared secret
+duplicated across two files with nothing keeping them equal; no `appsettings.Production.json`.
+
+**G. Unfiled REQ (1).** `ui-phase-11-report.md` records "REQ-061…064 appended" to the ledger. **Only
+062/063/064 were.** Ten live client files cite REQ-061 for the admin user directory
+(`admin_users/search`, `admin_users/lookup`). Phase 4 must **file it**, not assume it is tracked.
+
+### Contradictions resolved here
+
+**C-6** · **C-7** · **C-8** · **C-9** closed; **C-2** closed as a documentation decision; **C-3**'s
+contract-layer half closed. Decisions recorded in
+[open-contradictions.md § Resolved](open-contradictions.md#resolved).
+
+### What Phase 3 should know
+
+- The seam facts it needs are settled and mechanically verified: camelCase bodies, snake_case URLs, bearer
+ header (**not** cookie auth), money as a digit string outbound and an int64 inbound, `Idempotency-Key` on
+ exactly two endpoints, 20 anonymous operations.
+- **`GET /api/v1/dev/last_otp/{phone}` is live on `api.balinyaar.ir`** — the deployment runs as Development
+ with a capture-safe SMS provider, so both gate conditions hold. Any flow doc that describes logging in
+ should say this is how you do it today, and that it is the deployment's largest exposure.
+- C-4 (the `dev-certs` step) is confirmed dead: the API binds plain HTTP locally, so there is no certificate
+ to trust. C-5's two bring-up worlds are real and give **different data** — the committed dev config points
+ at the same remote, already-seeded database the deployment uses.
diff --git a/docs/integration/api-contract.md b/docs/integration/api-contract.md
new file mode 100644
index 0000000..7689f3a
--- /dev/null
+++ b/docs/integration/api-contract.md
@@ -0,0 +1,284 @@
+# The API contract
+
+Everything that holds for **every** Balinyaar endpoint. The per-domain files in
+[`domains/`](domains/index.md) assume all of this and never restate it.
+
+> Last verified: 2026-07-30 against commit `d3ec723`. Wire facts were derived mechanically from
+> [`openapi/swagger.v1.json`](openapi/swagger.v1.json) (2026-07-29) and read out of the code named
+> beside each claim. **When the JSON and this file disagree, the JSON wins and this file is wrong.**
+
+---
+
+## Base, versioning, routing
+
+| | |
+| --- | --- |
+| Client base URL | `NEXT_PUBLIC_API_URL` → [`client/src/config.ts`](../../client/src/config.ts) `API_URL` (required; boot fails without it) |
+| Local | `http://localhost:5002` — **plain HTTP**, per `launchSettings.json`. There is no local TLS and no certificate to trust |
+| Deployed | `https://api.balinyaar.ir` → Caddy → `balinyaar-api:8080` |
+| Route template | `api/v{version:apiVersion}/[controller]/[action]`, `DefaultApiVersion = 1.0` |
+| Segment casing | snake_case, via `SnakeCaseParameterTransformer` (`RouteTokenTransformerConvention`) |
+| Swagger UI | `/swagger` · ReDoc `/api-docs/{documentName}` · documents `v1` and `v1.1` |
+
+Route strings are **never hardcoded** server-side — the `[controller]`/`[action]` tokens also derive the
+dynamic-permission key, so renaming a handler renames its URL *and* its permission
+([server/CLAUDE.md](../../server/CLAUDE.md) hard rule 4).
+
+`v1.1` is registered (`AddSwagger("v1","v1.1")`) but **empty**: all 55 controllers are `[ApiVersion("1")]`
+and `ApiVersionDocumentProcessor` drops every path whose URL lacks the document's version segment.
+
+## The envelope
+
+`Baya.Application/Models/ApiResult/ApiResult.cs`, applied by `ApiResultFilterAttribute` +
+`base.OperationResult(result)`.
+
+| Field | Type | Notes |
+| --- | --- | --- |
+| `isSuccess` | boolean | |
+| `statusCode` | **integer** | `ApiResultStatusCode`: `200 400 401 403 404 406 409 422 424 500`. Not a string |
+| `message` | string \| null | User-safe. Defaults to the status's display name (`"Success"`, `"Bad Request Error"`, …) |
+| `requestId` | string \| null | `Activity.Current.TraceId` as hex — the W3C trace id, empty string if no activity |
+| `code` | string \| null | Optional stable machine-readable error code. **Omitted from the wire when null** |
+| `data` | `T` \| null | The payload. Present only on `ApiResult` |
+
+Failure responses use the same shape with `data` null (or the validation dictionary, below).
+
+**Client-side drift:** `ApiEnvelope` in
+[`client/src/lib/api/types.ts`](../../client/src/lib/api/types.ts) declares `isSuccess`, `statusCode`,
+`message`, `requestId`, `data` — but **not `code`**, even though `clientFetch` reads `body.code` at
+runtime and threads it into `ApiError`. Harmless today, incomplete as a type.
+
+## Casing
+
+**JSON bodies are camelCase. URL segments are snake_case.** REQ-001 settled this; verified here
+mechanically — across all 339 component schemas there are **427 distinct property names, 0 containing an
+underscore and 0 in PascalCase**.
+
+Query parameters are the exception with no single rule: most are camelCase, and `GET /search/nurses`
+takes snake_case (`service_category_id`, `city_id`, `district_id`, `nurse_gender`, `min_price`,
+`max_price`, `page_size`) — the only endpoint that does. See [domains/search.md](domains/search.md).
+
+## Status codes
+
+| Code | Meaning | Server source |
+| --- | --- | --- |
+| `200` | Success, payload in `data` | |
+| `400` | Validation / business-rule failure | `data` is `{ "": ["", …] }` (`ApiResultOfDictionaryOfStringAndListOfString`), from `ModelStateValidationAttribute` + FluentValidation |
+| `401` | Unauthenticated — missing, expired or unreadable token | |
+| `403` | Authenticated but lacks the policy/permission | |
+| `404` | Not found — **and a tenancy mismatch.** Deliberate: a 403 would confirm the row exists ([server/CLAUDE.md](../../server/CLAUDE.md) rule 20) | |
+| `406` | Not acceptable | |
+| `409` | Conflict — forward-only state-machine violation, duplicate, or converged idempotent replay | `OperationResult.ConflictResult` |
+| `422` | Entity process error | |
+| `424` | Failed dependency (an external rail refused) | |
+| `429` | Rate limited | The rate limiter, below |
+| `5xx` | Unexpected. Generic message; detail only in logs — **except** in Development, where the developer exception page returns a stack trace | `ExceptionHandler` |
+
+Handlers **never throw for an expected failure** — they return `OperationResult.FailureResult` /
+`NotFoundResult` / `ConflictResult`, which the filter maps to the codes above.
+
+### What the client does with each
+
+[`client/src/lib/api/client.ts`](../../client/src/lib/api/client.ts):
+
+| | Behaviour |
+| --- | --- |
+| `401` | One silent refresh + retry (single-flight). On failure: clear both auth cookies, toast "session expired", `window.location.replace('/{locale}/login')`, **return `undefined` rather than throw** |
+| `403` | Toast + throw `ApiError(403, message, code)` |
+| `5xx` | Toast + throw `ApiError` |
+| other `4xx` | **Throw without toasting** — the calling hook owns the user-facing message |
+| network failure | Toast + `throw new ApiError(0, 'Network error')` |
+| `204` | Returns `undefined` |
+
+`serverFetch` ([`server.ts`](../../client/src/lib/api/server.ts)) never toasts and never redirects — every
+non-OK response throws `ApiError` and the caller decides between `notFound()`, `redirect()` and an error
+boundary.
+
+## Auth
+
+### Transport is a header, storage is a cookie
+
+The JWE access token lives in a **client-readable cookie** (`access_token`) that the client reads itself
+and re-sends as `Authorization: Bearer `. **No cookie crosses the wire as an auth credential.**
+Consequences:
+
+- The server's CORS policy sets **no** `AllowCredentials()` — see [config-matrix.md](config-matrix.md#cors).
+- Neither fetch layer sets `credentials: 'include'`.
+- A cross-site cookie policy (`SameSite`) is irrelevant to API auth; the cookies are same-origin storage.
+
+| Cookie | Options | Written by |
+| --- | --- | --- |
+| `access_token` | `path=/`, `maxAge=900` (15 min), `sameSite=lax`, `secure` | `persistAuthTokens` |
+| `refresh_token` | `path=/`, `maxAge=604800` (7 days), `sameSite=lax`, `secure` | `persistAuthTokens` |
+
+> **Asymmetry worth knowing:** the cookie's `maxAge` is 15 minutes but `IdentitySettings:ExpirationMinutes`
+> is **60**. The token stays valid for an hour; the client simply stops having it after 15 minutes, so the
+> next call goes out unauthenticated, gets a 401, and refreshes. It works, but the refresh cadence is set
+> by the cookie, not the token.
+
+### The token is opaque
+
+It is a **JWE** — signed *and* AES-128-encrypted. The client cannot read a claim out of it and must not
+try. Identity, roles and profile-completeness come from `GET /api/v1/me`; a user holding more than one
+role commits to one with `POST /api/v1/me/select_role`. See [domains/auth.md](domains/auth.md) and
+[docs/rules/client/auth.md](../rules/client/auth.md).
+
+### Refresh, rotation, reuse detection
+
+`POST /api/v1/auth/refresh` with `{ refreshToken }` returns a **new pair**; the old refresh token is
+retired. Presenting a retired token is treated as theft and kills the session. The client coalesces
+concurrent 401s into **one** refresh via a module-level in-flight promise
+([`refresh.ts`](../../client/src/lib/api/refresh.ts)) and retries the original request exactly once.
+`/auth/refresh`, `/auth/request_otp` and `/auth/verify_otp` are excluded from the retry — a 401 there is
+terminal.
+
+### Authorization model
+
+Three levels, declared per controller:
+
+| Attribute | Used by | Meaning |
+| --- | --- | --- |
+| *(none)* | 7 controllers | Anonymous — there is **no** `FallbackPolicy`, so an omitted attribute *is* the decision |
+| `[Authorize]` | user-facing controllers | Any authenticated caller; tenancy is then resolved from `ICurrentUser` |
+| `[Authorize(ConstantPolicies.DynamicPermission)]` | every `Admin*` controller + `Holidays`, `PlatformConfig`, `SupportAlerts`, `Audit`, `InternalCenters` | Dynamic permission keyed off the controller/action route |
+
+### The anonymous surface
+
+20 of 186 operations declare no security. This is the complete list:
+
+```
+POST /api/v1/auth/request_otp POST /api/v1/auth/verify_otp
+GET /api/v1/catalog/categories GET /api/v1/catalog/option_groups
+GET /api/v1/geo/provinces GET /api/v1/geo/cities
+GET /api/v1/geo/districts GET /api/v1/geo/tree
+GET /api/v1/nurses/{nurseId}/profile GET /api/v1/nurses/{nurseId}/trust_badge
+GET /api/v1/nurses/{id}/reviews GET /api/v1/nurses/{id}/review_tags
+GET /api/v1/nurse_variants/get/{id} GET /api/v1/search/nurses
+GET /api/v1/ping/get_status GET /api/v1/ping/get_status_rate_limited
+POST /api/v1/webhooks/payments/{provider} POST /api/v1/webhooks_bnpl/{provider}
+POST /api/v1/webhooks/payouts/{provider} GET /api/v1/dev/last_otp/{phone}
+```
+
+Two things follow that the REQ ledger has not caught up with:
+
+- **REQ-066/067** ask for anonymous nurse search + profile reads for guest browse and are filed *open*.
+ Those endpoints are **already anonymous**. What is genuinely missing is the rate limit the REQ asks for
+ (`SearchController` and `NursesController` carry no `[EnableRateLimiting]`, so they fall to the 100/min
+ per-IP global limiter) and the privacy review of the profile payload.
+- `GET /api/v1/dev/last_otp/{phone}` returns any registered phone's login code. It is Development-only
+ code, and the deployment runs as Development — **so it is live on `api.balinyaar.ir`**. Recorded in
+ [DEPLOY.md](../../DEPLOY.md) as the deployment's largest exposure.
+
+## Localisation
+
+The client sends `Accept-Language` (`fa` default) on **every** call, taken from the URL's locale segment.
+`serverFetch` reads the locale from its own `x-app-locale` request header (`HEADER_NAMES.LOCALE`, set by
+the Next.js middleware) and forwards it as `Accept-Language`. Reference data carrying `nameFa`/`nameEn`
+returns both and the client picks.
+
+## Pagination
+
+Every unbounded list is paginated. Payload: `{ items, total, page, pageSize }` — `total`, `page` and
+`pageSize` are `integer/int32`, `items` is nullable.
+
+29 operations take paging params, in three declared spellings:
+
+| Declared | Count | Endpoints |
+| --- | --- | --- |
+| `Page`, `PageSize` | 25 | the default — every `*/list`, `admin_*` worklist, `tickets`, `notifications`, … |
+| `page`, `pageSize` | 3 | `admin_payouts/batches/{id}` · `nurses/{id}/reviews` · `patients/{id}/care_records` |
+| `page`, **`page_size`** | 1 | `search/nurses` |
+
+The first two are interchangeable — ASP.NET model binding is case-insensitive, which is what REQ-010
+recorded. **`page_size` is not**: it is a different name, and sending `pageSize` to `search/nurses` binds
+nothing and silently yields the default page size. The client's search client already sends `page_size`
+correctly.
+
+## Idempotency
+
+`Idempotency-Key`, a request header. **Read on exactly two endpoints**, both via
+`Request.Headers["Idempotency-Key"].FirstOrDefault()`:
+
+| Endpoint | Controller | Semantics |
+| --- | --- | --- |
+| `POST /api/v1/bookings/{bookingRequestId}/payments` | `PaymentsController` | One key per payment **attempt**, reused across retries of that attempt. A new attempt takes a new key. `409` means "already in progress / already captured" — a benign convergence, not an error |
+| `POST /api/v1/checkout_bnpl/initiate` | `CheckoutBnplController` | Same, per BNPL attempt |
+
+Because it is read from the header rather than bound as a parameter, **it appears nowhere in swagger.**
+It *is* in the CORS allow-list, so the pre-flight passes.
+
+Money-path writes are idempotent by construction regardless of the header: the webhook event is upserted
+first and a duplicate no-ops, `bookings.booking_request_id` is `UNIQUE` so a replayed conversion cannot
+create a second booking, and a unique-violation on confirm is treated as idempotent success. **The DB
+constraint is the backstop, not the handler's `if`.**
+
+Webhooks do **not** use the header — they dedupe on the provider's `external_event_id`.
+
+## Money on the wire
+
+| Direction | Type | Count | Rule |
+| --- | --- | --- | --- |
+| Outbound — DTOs, results | **digit string** (`"23300000"`) | 68 / 68 | Parse with the `@/utils` BigInt helpers. **Never `Number()`** |
+| Inbound — `*Command` / `*Request` bodies | **`integer/int64`** | 3 / 3 | `UpsertCancellationPolicyCommand.feeAmountIrr`, `CreateRefundCommand.platformFeeRefundedIrr`, `CreateRefundCommand.nursePayoutRefundedIrr` |
+
+Invariants the client must not recompute: `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`;
+VAT applies to Balinyaar's commission only, never the nurse payout; a rate change is never retroactive
+(the rate is snapshotted onto the row at compute time). Toman↔IRR conversion happens only inside a
+provider adapter. Payout dates are resolved server-side against the holiday calendar — the client never
+computes one.
+
+## Dates, ids, PII
+
+- Timestamps are **UTC ISO-8601** strings. Shamsi rendering is a client concern.
+- `dayOfWeek` for availability uses the **Shamsi week** (0 = Saturday … 6 = Friday), not ISO Monday-start.
+- Entity ids are integers. Human-facing references (`referenceCode` on tickets, `invoiceNumber`) are
+ opaque strings.
+- Encrypted-at-rest fields (phone, national id, IBAN, addresses, clinical notes) are returned only to
+ authorised callers and often masked. Each domain file states masked vs. full.
+- **Two-stage clinical disclosure:** a booking *request* exposes only unencrypted `customerNotes` and a
+ city/district-coarse address; encrypted care instructions become readable only after confirmation, only
+ to the assigned nurse and admin, and are never projected into a list.
+- `gender` is load-bearing — it drives same-gender caregiver matching. Never defaulted, never dropped.
+
+## Enums
+
+**Swagger carries no string enums.** Of 339 schemas exactly one has an `enum`, and it is the integer
+`ApiResultStatusCode`. Every status/type/code field serialises as a bare `string`, so the JSON cannot
+validate a vocabulary and **the domain files here are the only written record.**
+
+Each vocabulary in [`domains/`](domains/index.md) was cross-checked against the server's `Baya.Domain`
+code sets and the client's string-literal unions. Both sides agree on all 18 shared vocabularies at this
+stamp, with one exception ([domains/tickets.md](domains/tickets.md): the client's `TicketAuthorRole`
+carries a `system` member the server's `TicketCodes` does not define).
+
+## Rate limits
+
+`RateLimitingServiceExtension`. Over-limit → **429**. Partitioned on the client IP as resolved by the
+forwarded-headers middleware, so behind Caddy each real client gets its own bucket.
+
+| Policy | Limit | Applied to |
+| --- | --- | --- |
+| *(global, implicit)* | 100 / min per IP | every endpoint that opts into nothing else |
+| `otp` | 5 / min | `auth/request_otp`, `auth/verify_otp` |
+| `auth` | 10 / min | `auth/refresh` |
+| `sensitive` | 20 / min | every `Admin*` controller, `checkout_bnpl`, `bookings` money actions, `booking_sessions` check-out, `nurse_bank_accounts` writes, `payments` |
+| `webhook` | 120 / min | the three webhook controllers, partitioned **per provider × IP** so one PSP's burst cannot starve another |
+| `global` (named) | 5 / 10 s | `ping/get_status_rate_limited` only — a demonstration endpoint |
+
+`app.UseCors()` runs **after** `UseRouting()` and **before** the rate limiter and authentication, so a
+pre-flight `OPTIONS` is answered rather than rejected as 429 or 401.
+
+## Platform endpoints (outside every domain)
+
+Not part of any `services/` domain, and intentionally so:
+
+| Endpoint | Purpose |
+| --- | --- |
+| `GET /api/v1/ping/get_status` | Liveness smoke test (anonymous) |
+| `GET /api/v1/ping/get_status_rate_limited` | Demonstrates a 429 (anonymous, 5/10 s) |
+| `GET /healthz/live` | Process only — never touches a dependency |
+| `GET /healthz/ready` | + app DB, log DB (deployed only), object-storage write probe |
+| `GET /HealthCheck` | The aggregate, kept for existing probes |
+| `GET /metrics` | Prometheus scrape (OpenTelemetry is the only metrics source) |
+
+The health and metrics endpoints are **not** under `/api/v1` and carry no envelope.
diff --git a/docs/integration/config-matrix.md b/docs/integration/config-matrix.md
new file mode 100644
index 0000000..6275286
--- /dev/null
+++ b/docs/integration/config-matrix.md
@@ -0,0 +1,264 @@
+# Config matrix
+
+Every configuration key on both sides of the seam, plus docker and the OTP relay, with where it is set and
+who reads it.
+
+> Last verified: 2026-07-30 against commit `d3ec723`. Built by **mechanically enumerating** every leaf key
+> in both `appsettings*.json`, every `environment:` entry in `docker-compose.yml`, every assignment in the
+> three `client/.env*` files and `telegram-otp-bot/.env.example`, and every `process.env.*` read under
+> `client/src/` — then diffing the sets. The gaps that diff found are in
+> [§ What the diff found](#what-the-diff-found).
+
+---
+
+## Configuration lives in files. `dotnet user-secrets` is not used.
+
+The `` was **removed** from `Baya.Web.Api.csproj`, so that store is **not even read**. A
+stale `secrets.json` on a developer machine is inert and can be deleted. Any instruction anywhere in this
+repo to set a Balinyaar value with `dotnet user-secrets` is stale — including the placeholder string
+`SET_VIA_USER_SECRETS_OR_ENV`, whose *name* is a historical artifact (see
+[§ The placeholder's name](#the-placeholders-name)).
+
+**Every value is a file in git**, which means **the repository contains live credentials** — a deliberate
+pre-launch trade for a demo deployment. Before onboarding real users they must be rotated and the secret
+half moved out of git: [DEPLOY.md § Going to Production](../../DEPLOY.md) and, when Phase 5 writes it,
+`docs/roadmap/pre-launch.md`.
+
+> ⚠️ **`Seams:FieldEncryption:Key` and `:HashKey` are load-bearing and must never change.** Every encrypted
+> column in the database — phones, addresses, IBANs, clinical notes — was written with those exact values,
+> and `users.PhoneHash`, which **every login** looks up, is derived from `HashKey`. Rotating either makes
+> the existing data unreadable and locks every account out. The JWE keys
+> (`IdentitySettings:SecretKey`/`Encryptkey`) are safe to rotate — that only signs everyone out.
+
+### There is no `appsettings.Production.json`
+
+Only `appsettings.json` (placeholders) and `appsettings.Development.json` (real values) exist. The
+deployment runs `ASPNETCORE_ENVIRONMENT=Development`, so **`appsettings.Development.json` *is* the
+production config.** Creating an `appsettings.Production.json` today would change nothing until the
+environment name changes too.
+
+---
+
+## Server — `appsettings.json` / `appsettings.Development.json`
+
+`appsettings.json` holds a rejected placeholder for every secret; `appsettings.Development.json` holds the
+real value. Environment-variable overrides use the double-underscore form
+(`Seams__Sms__Telegram__BaseUrl`).
+
+| Key | Set in | Read by | Required | Default / committed value |
+| --- | --- | --- | --- | --- |
+| `ConnectionStrings:SqlServer` | both | `AddPersistenceServices`, `StartupSecretsGuard`, readiness probe | **yes, always** | placeholder / `87.107.152.16,1433;Database=Baya` |
+| `ConnectionStrings:logDb` | both | Serilog sink, `StartupSecretsGuard`, readiness probe (**deployed only**) | **yes, always** | placeholder / `…;Database=Baya_Logs` |
+| `IdentitySettings:SecretKey` | both | JWE signing | yes **when deployed** | placeholder / `dev-only-…-not-for-production` |
+| `IdentitySettings:Encryptkey` | both | JWE AES-128 encryption | yes **when deployed** | placeholder / `dev-only-16bytes` |
+| `IdentitySettings:Issuer` | both | token validation | — | `Balinyaar` |
+| `IdentitySettings:Audience` | both | token validation | — | `BalinyaarClient` |
+| `IdentitySettings:NotBeforeMinutes` | both | token validation | — | `0` |
+| `IdentitySettings:ExpirationMinutes` | both | access-token lifetime | — | `60` — **but the client's cookie expires at 15 min**, see [api-contract.md](api-contract.md#auth) |
+| **`Seams:FieldEncryption:Key`** | both | `IFieldEncryptor` (process-wide singleton) | yes **when deployed** | placeholder / `local-dev-field-encryption-key-not-for-production` · **IMMUTABLE** |
+| **`Seams:FieldEncryption:HashKey`** | both | deterministic lookup hashes incl. `users.PhoneHash` | yes **when deployed** | placeholder / `local-dev-field-hash-key-not-for-production` · **IMMUTABLE** |
+| `Seams:ObjectStorage:RootPath` | both + compose | local-disk blob root | when provider = `local` | `""` / compose sets `/app/data/object-storage` |
+| `Seams:Sms:Provider` | Dev only | SMS seam selector **and** the OTP-capture gate in `Program.cs` | — | `telegram` (default in code: `mock`) |
+| `Seams:Sms:Telegram:BaseUrl` | Dev + **compose** | `TelegramSmsSender` | when provider = `telegram` | `http://127.0.0.1:5010` / compose: `http://balinyaar-otp-relay:5010` |
+| `Seams:Sms:Telegram:ApiKey` | Dev only | relay `X-Api-Key` — **must equal the relay's `API_KEY`** | when provider = `telegram` | `6a8dfaee…` (rotated away from the published example) |
+| `Seams:Sms:Telegram:TimeoutSeconds` | Dev only | HTTP timeout | — | `10` |
+| `Seams:Geocoding:ReturnNullCoordinates` | both | mock geocoder | — | `false` |
+| `Seams:Geocoding:LowConfidenceMarker` | both | mock geocoder | — | `NO_GEO` |
+| `Seams:Geocoding:ResolvedConfidence` | both | mock geocoder | — | `0.9` |
+| `Cors:AllowedOrigins` | both | `AddCorsPolicies` | — | `[]` → falls back to `http://localhost:3000` / the three real origins |
+| `ForwardedHeaders:KnownProxies` | both | `AddForwardedHeadersConfiguration` | — | `[]` |
+| `ForwardedHeaders:KnownNetworks` | both | ditto — **required for the rate limiter to see the real client IP behind Caddy** | deployed | `[]` / the three docker bridge ranges |
+| `AllowedHosts` | both | host filtering | — | `*` |
+| `Kestrel:EndpointDefaults:Protocols` | both | Kestrel — `Http1AndHttp2` is what lets gRPC share the port | — | `Http1AndHttp2` |
+
+### Keys the code reads that no file sets
+
+Every one has a working default, so nothing is broken — but none is discoverable from the config files.
+
+| Key | Read by | Behaviour when unset | Notes |
+| --- | --- | --- | --- |
+| `OpenTelemetry:Otlp:Endpoint` | `SetupOpenTelemetry` | **OTLP export is not wired at all.** Prometheus `/metrics` still works | Opt-in by design, so no exporter spams an absent collector |
+| `Search:Backend` | `AddPersistenceServices` | `SqlNurseSearch` | Any value other than `sql`/empty **throws at startup** — Elasticsearch is deferred and fails loudly |
+| `Seed:AdminUsername` / `:AdminPassword` / `:AdminEmail` | `SeedDataBase` | no break-glass admin is seeded | The hardcoded `admin`/`qw123321` was removed in refinement-phase-5 and the pre-commit hook blocks its return |
+| `Seams::Provider` (×11) | `AddCrossCuttingSeams` | **`mock`** | The seam selectors, below |
+
+### The seam selectors
+
+`SeamOptions` binds the whole `Seams` section. **Every rail defaults to its mock**, so an unconfigured
+environment behaves exactly as before and a **partial rollout is the normal case** — real SMS and a real
+geocoder while payments stay mocked is three config keys.
+
+| Selector | `mock` (default) → | Real values |
+| --- | --- | --- |
+| `Seams:Sms:Provider` | log the OTP | `kavenegar` `smsir` `ghasedak` · `telegram` *(Development relay, not a gateway)* |
+| `Seams:ObjectStorage:Provider` | `local` disk | `s3` (MinIO / ArvanCloud, path-style) |
+| `Seams:Geocoding:Provider` | deterministic point | `neshan` |
+| `Seams:Shahkar:Provider` | designated test values | `finnotech` |
+| `Seams:IdentityKyc:Provider` | designated test values | `finnotech` |
+| `Seams:BankOwnership:Provider` | designated test values | `finnotech` |
+| `Seams:Payments:Provider` | deterministic capture | `zarinpal` `sadad` `vandar` `jibit` |
+| `Seams:Bnpl:Provider` | one mock provider | `real` → `IBnplProviderResolver` per `provider_code` |
+| `Seams:BankTransfer:Provider` | settles every payout | `jibit` `vandar` `sadad` |
+| `Seams:Moadian:Provider` | stays `pending` | `moadian` |
+| `Seams:Currency` *(no selector)* | `TomanToIrrMultiplier = 10` | a redenomination is a config change |
+
+`Seams:Finnotech:{BaseUrl,ClientId,AccessToken}` are shared by the three trust rails — they authenticate
+against one tenant, so the connection facts live once.
+
+Each mock also carries **test knobs** whose only purpose is to make a failure path reachable:
+`Shahkar:SharedSimPhone` `09120000000` · `Shahkar:MismatchNationalId` `1111111111` ·
+`IdentityKyc:FailNationalId` `0000000000` · `BankOwnership:MismatchIban` `IR0000…0000` ·
+`Bnpl:NotEligibleMobile` `09120000099` · `BankTransfer:FailIban` · `BankTransfer:ForceFailure` ·
+`PaymentCapture:ForceFailure` · `Moadian:ForceRegistered` · `ReviewModeration:AutoApproveClean` ·
+`LicenseVerification:AutoApprove` · `Payments:InvalidSignatureMarker` `INVALID_SIGNATURE`.
+
+---
+
+## Client — `client/.env.*`
+
+**Every `NEXT_PUBLIC_*` value is inlined into the browser bundle at build time.** It is public by
+definition, and changing one requires **rebuilding the image**, not restarting the container. This is why
+`docker-compose.yml` deliberately sets no `environment:` for the `web` service — anything there would be
+silently ignored.
+
+| Key | `.env.development` | `.env.production` | Read by | Required |
+| --- | --- | --- | --- | --- |
+| `NEXT_PUBLIC_API_URL` | `http://localhost:5002` | `https://api.balinyaar.ir` | `config.ts` → `API_URL` | **yes** — `envRequired`, boot fails without it |
+| `NEXT_PUBLIC_ENV` | `development` | `production` | `getCurrentEnvironment()` → `IS_PRODUCTION` | — |
+| `NEXT_PUBLIC_DEBUG` | `true` | `false` | `IS_DEBUG` — `true` **prints the resolved config, including the API URL, to the browser console** | — |
+| `NEXT_PUBLIC_PUBLIC_URL` | `http://localhost:3000` | `https://balinyaar.ir` | `PUBLIC_URL` (optional) | — |
+| `NEXT_PUBLIC_SITE_URL` | *(unset)* | `https://balinyaar.ir` | `SITE_URL` — **metadata only** (OG tags, `metadataBase`, `robots.ts`, `sitemap.ts`), never API calls | — · falls back to `http://localhost:3000` |
+| `NEXT_PUBLIC_NESHAN_KEY` | *(unset)* | *(commented out)* | `NESHAN_WEB_KEY` — the Neshan **web** key | — · unset ⇒ `AddressMapPicker` uses its bounded-canvas grid, so dev/CI/jsdom work without it |
+| `NEXT_PUBLIC_EVV_MOCK_GPS` | **not in any file** | **not in any file** | `bookings/constants.ts` | — · `in_range` when the bookings mock is on, else `off`. Values: `off` `in_range` `out_of_range` `denied` |
+| `NEXT_PUBLIC_VERSION` | **not in any file** | **not in any file** | `getCurrentVersion()`, after `npm_package_version` | — · falls back to `'unknown'` |
+
+> **Two Neshan keys exist and they are different products.** `NEXT_PUBLIC_NESHAN_KEY` is a client-embeddable
+> **web** key; `Seams:Geocoding:ApiKey` is the **server** geocoding key. Never share one value between them.
+
+`client/.env.sample` is the copy-me template for a fresh clone. It is **not** loaded by Next.js.
+
+---
+
+## Docker — `docker-compose.yml`
+
+Three containers, **no published ports** — everything is reached through the existing Caddy on the external
+`caddy_net`. Full graph in [topology.md](topology.md).
+
+| Service | Variable | Value | Why it is here and not in a file |
+| --- | --- | --- | --- |
+| `api` | `ASPNETCORE_ENVIRONMENT` | `Development` | **Deliberate**, so the demo + lifecycle seeders populate the shared DB. Consequences in [DEPLOY.md](../../DEPLOY.md) |
+| `api` | `Seams__Sms__Telegram__BaseUrl` | `http://balinyaar-otp-relay:5010` | Container DNS instead of loopback |
+| `api` | `Seams__ObjectStorage__RootPath` | `/app/data/object-storage` | Must land on the named volume |
+| `web` | *(none)* | — | Every `NEXT_PUBLIC_*` is baked at build time; a variable here would be ignored |
+| `otp-relay` | `TELEGRAM_BOT_TOKEN` | `8968527151:AAF…` | Live credential in git |
+| `otp-relay` | `TELEGRAM_CHAT_IDS` | `1277103616,110209855` | **Every id receives every login code, for every phone number** |
+| `otp-relay` | `API_KEY` | `6a8dfaee…` | **Must equal `Seams:Sms:Telegram:ApiKey`** |
+| `otp-relay` | `TELEGRAM_PROXY_URL` | `http://hysteria-client:8081` | `api.telegram.org` is filtered in Iran; a wrong value fails at boot, not per-OTP |
+| `otp-relay` | `REDACT_CODE_IN_LOGS` | `"true"` | Keeps codes out of `docker logs` so a host-log reader cannot harvest them |
+
+Volumes: `api-object-storage` (uploaded verification documents — **losing it breaks the admin queue**) and
+`api-logs` (Serilog file sink).
+
+## Caddy — `deploy/Caddyfile`
+
+Not loaded by anything in this repo; it is a copy of the block `DEPLOY.md` tells you to paste into the
+Caddy container that owns `caddy_net`.
+
+| Hostname | Upstream | Notes |
+| --- | --- | --- |
+| `balinyaar.ir`, `www.balinyaar.ir` | `balinyaar-web:3000` | |
+| `api.balinyaar.ir` | `balinyaar-api:8080` | **8080 in-container, not 5002** — 5002 is the local `launchSettings.json` port |
+
+Caddy is the only TLS terminator and renews both certificates itself. It sets `X-Forwarded-For` and
+`X-Forwarded-Proto` by default, which is why no header directives are needed — but the API must trust the
+hop via `ForwardedHeaders:KnownNetworks`, or the rate limiter partitions every request onto Caddy's IP.
+
+## CORS
+
+`CorsServiceExtension`, policy `BalinyaarWebClient`.
+
+| | |
+| --- | --- |
+| Origins | `Cors:AllowedOrigins`; falls back to `http://localhost:3000` when unset or empty |
+| Headers | `Authorization` · `Content-Type` · `Accept-Language` · `Idempotency-Key` — explicit, **not** `AllowAnyHeader`, so the surface is auditable |
+| Methods | any |
+| **Credentials** | **not allowed.** The client authenticates with a bearer header, not a cookie, so `AllowCredentials()` is unnecessary |
+| Pipeline position | after `UseRouting()`, **before** the rate limiter and authentication, so a pre-flight `OPTIONS` is answered rather than rejected as 429/401 |
+
+Adding a browser origin means editing `Cors:AllowedOrigins` **and** rebuilding the client if its
+`NEXT_PUBLIC_API_URL` changes.
+
+## Telegram OTP relay — `telegram-otp-bot`
+
+A standalone zero-dependency Node service. **It is not an SMS gateway**: there is no per-user routing — it
+*broadcasts* every code to a fixed list of chat ids. Workable for a trusted demo group, disqualifying the
+moment anyone outside it can request a code. Switching `Seams:Sms:Provider` to `kavenegar` at that point
+changes nothing else.
+
+| Variable | Required | Default | Notes |
+| --- | --- | --- | --- |
+| `TELEGRAM_BOT_TOKEN` | **yes** | — | From @BotFather |
+| `TELEGRAM_CHAT_IDS` | **yes** | — | Comma-separated. **Each recipient must have messaged the bot first** — Telegram forbids a bot opening a conversation |
+| `API_KEY` | **yes** | — | Min 16 chars; the process refuses to start without it. Must equal `Seams:Sms:Telegram:ApiKey`. **The value in `.env.example` is published, and `TelegramSmsSender` deliberately refuses to authenticate with it** |
+| `PORT` | — | `5010` | |
+| `HOST` | — | `127.0.0.1` | |
+| `REDACT_CODE_IN_LOGS` | — | `false` | `true` in the deployment |
+| `TELEGRAM_PROXY_URL` | — | unset ⇒ direct | HTTP CONNECT or SOCKS5. `HTTPS_PROXY`/`ALL_PROXY` are honoured as a fallback |
+
+`telegram-otp-bot/.env` is for **local `npm start` only** — nothing in it is read inside the container.
+
+## The database is not containerised
+
+It is a remote SQL Server at `87.107.152.16:1433`, already provisioned and already seeded. Nothing in
+`docker-compose.yml` creates it; the API only needs network reach. Two databases: `Baya` (app) and
+`Baya_Logs` (Serilog sink).
+
+## Health, metrics, and the secrets guard
+
+| Endpoint | Checks |
+| --- | --- |
+| `/healthz/live` | process only — deliberately dependency-free, so a dependency outage never restarts a healthy instance |
+| `/healthz/ready` | app DB · log DB (**deployed only** — its connection string is a placeholder in Development) · an object-storage **write** round-trip |
+| `/HealthCheck` | the aggregate, retained for existing probes |
+| `/metrics` | Prometheus scrape. OpenTelemetry is the only metrics source; the duplicate prometheus-net stack was removed |
+
+`StartupSecretsGuard` runs before any service reads configuration and **refuses to boot** on a missing or
+placeholder value. It requires both connection strings in every environment, and the four crypto keys only
+when **not** Development. It is skipped entirely in the `Testing` environment. Placeholder markers:
+`SET_VIA_USER_SECRETS_OR_ENV`, `not-for-production`, `change-me`,
+`ShouldBe-LongerThan-16Char-SecretKey`, `16CharEncryptKey`.
+
+---
+
+## What the diff found
+
+Enumerating both sets and subtracting them surfaced five things. None is a broken deployment; all five are
+places where the config is not discoverable from the config files.
+
+1. **`NEXT_PUBLIC_EVV_MOCK_GPS` and `NEXT_PUBLIC_VERSION` are read by client code and declared in no `.env`
+ file.** Both have working defaults. Adding them commented-out to `.env.sample` would make them findable.
+2. **`OpenTelemetry:Otlp:Endpoint`, `Search:Backend` and `Seed:Admin*` are read by server code and set
+ nowhere.** All three are intentionally opt-in, but a reader of `appsettings.json` cannot learn they
+ exist. `Search:Backend` is the sharpest: a wrong value **throws at startup**.
+3. **`client/.env.sample` still says `NEXT_PUBLIC_API_URL = https://localhost:5002`** — the `https` half of
+ contradiction **C-3**, in the one file a fresh clone is meant to copy. `.env.development` has the
+ correct `http://`.
+4. **`Seams:Sms:Telegram:ApiKey` and the relay's `API_KEY` are the same secret in two files** with no
+ mechanism keeping them equal. They currently match. A mismatch fails every OTP send at runtime, not at
+ boot.
+5. **There is no `appsettings.Production.json`,** and `DEPLOY.md` step 2 of "Going to Production" is
+ therefore a *create*, not an *edit*.
+
+### The placeholder's name
+
+`SET_VIA_USER_SECRETS_OR_ENV` names a store that no longer exists (contradiction **C-2**). The *behaviour*
+is correct — it is a sentinel that `StartupSecretsGuard` and the pre-commit hook both reject — but the name
+instructs a reader to use a removed mechanism.
+
+It was **not renamed in this phase**, because the string is load-bearing in seven live files:
+`appsettings.json` (×6), `StartupSecretsGuard.cs`, `.githooks/pre-commit`, `.githooks/README.md`,
+`Baya.Test.Api/StartupSecretsGuardTests.cs` (×2), `docs/rules/shared/git-and-gates.md` and
+`docs/rules/server/structure.md`. Renaming it is a server-code + git-hook + test change requiring
+`dotnet build` and `dotnet test` to prove the gate still fires — out of scope for a documentation phase.
+**This section is the authoritative statement of the mechanism**; the rename is filed for Phase 4 with that
+exact seven-file worklist.
diff --git a/docs/integration/domains/addresses.md b/docs/integration/domains/addresses.md
new file mode 100644
index 0000000..565cee0
--- /dev/null
+++ b/docs/integration/domains/addresses.md
@@ -0,0 +1,45 @@
+# addresses — customer addresses
+
+> Client seam `client/src/services/addresses/` · `USE_ADDRESSES_MOCK = false` (**real**) · 5 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+The customer's saved addresses. One address is primary; the rest are ordered by recency. The address a
+booking uses is **snapshotted** onto the booking, so editing an address later never rewrites history.
+
+## Endpoints
+
+| Method | Path | Auth | Verdict |
+| --- | --- | --- | --- |
+| GET | `/api/v1/customer_addresses/list` | `[Authorize]` | wired · paginated (`Page`/`PageSize`) |
+| POST | `/api/v1/customer_addresses/create` | `[Authorize]` | wired |
+| POST | `/api/v1/customer_addresses/update/{id}` | `[Authorize]` | wired |
+| POST | `/api/v1/customer_addresses/set_primary/{id}` | `[Authorize]` | wired |
+| DELETE | `/api/v1/customer_addresses/delete/{id}` | `[Authorize]` | wired · **soft delete** |
+
+No phantoms. The domain maps 1:1.
+
+## Shape rules the JSON does not express
+
+- **`provinceId` is on `CustomerAddressDto`** (REQ-009, delivered). The client needs it to preselect the
+ province in the city cascade without a reverse lookup.
+- **The client-picked map pin is accepted on create and update** (REQ-008, delivered) — the server does
+ **not** re-geocode over a pin the user placed. When no pin is given, `IGeocoder` resolves one.
+- **`latitude`/`longitude` are nullable.** Null means the geocoder could not resolve the address and the
+ user placed no pin; the UI shows "saved without a map pin" rather than an error. `IGeocoder`'s mock
+ forces this path for any address whose text contains `NO_GEO`
+ (`Seams:Geocoding:LowConfidenceMarker`).
+- **The full address line is encrypted at rest** and returned decrypted only to its owner. A *booking
+ request* sees a city/district-coarse mask instead — see [booking-requests.md](booking-requests.md).
+- **`set_primary` touches two rows** (demote the old, promote the new) in one transaction. The client
+ invalidates the whole list key rather than patching one item.
+- Delete is a soft delete behind the entity's global query filter; a booking that snapshotted the address
+ is unaffected.
+
+## Enums
+
+None of its own. `provinceId` / `cityId` / `districtId` are geography ids — see
+[geography.md](geography.md), where **`districtId = null` means whole-city**.
+
+## Open REQs
+
+None. REQ-008 and REQ-009 were both delivered in refinement-phase-3 and are folded into the rules above.
diff --git a/docs/integration/domains/admin.md b/docs/integration/domains/admin.md
new file mode 100644
index 0000000..55f1295
--- /dev/null
+++ b/docs/integration/domains/admin.md
@@ -0,0 +1,99 @@
+# admin — platform config, holidays, audit, support alerts
+
+> Client seam `client/src/services/admin/` · `USE_ADMIN_MOCK = true` (**mock is primary**) · 14 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+The ops console's cross-cutting reads and writes. Domain-specific admin surfaces live with their domain —
+verification admin in [verification.md](verification.md), refunds in [refunds.md](refunds.md), payouts in
+[payouts.md](payouts.md), catalog in [catalog.md](catalog.md), geo in [geography.md](geography.md),
+partner centers in [partner-center.md](partner-center.md), the ticket queue in [tickets.md](tickets.md).
+
+Every endpoint here is `[Authorize(ConstantPolicies.DynamicPermission)]` + `sensitive` rate limit
+(20/min), **except** the three `platform_config`/`holidays`/`audit`/`support_alerts` controllers, which
+carry the dynamic-permission policy without the sensitive limit — they fall to the 100/min global limiter.
+
+## Endpoints
+
+| Method | Path | Verdict |
+| --- | --- | --- |
+| GET | `/api/v1/platform_config/get_platform_configs` | wired · paginated |
+| POST | `/api/v1/platform_config/update_platform_config` | wired |
+| GET | `/api/v1/platform_config/get_config_change_history` | wired · paginated |
+| GET | `/api/v1/holidays/get_holidays` | wired · paginated |
+| POST | `/api/v1/holidays/upsert_holiday` | wired |
+| POST | `/api/v1/holidays/delete_holiday` | **unwired** — no real client caller; the console offers no delete |
+| GET | `/api/v1/audit/get_audit_trail` | wired · paginated |
+| GET | `/api/v1/support_alerts/get_support_alerts` | wired · paginated |
+| POST | `/api/v1/support_alerts/assign_support_alert` | wired |
+| POST | `/api/v1/support_alerts/resolve_support_alert` | wired |
+| GET | `/api/v1/admin_cancellation_policies/list` | **unwired** — the tier table is read through [refunds.md](refunds.md)'s policy preview instead |
+| POST | `/api/v1/admin_cancellation_policies/upsert` | **unwired** — no console screen edits tiers |
+| POST | `/api/v1/admin_search/rebuild_index` | **unwired** — an ops one-shot, no UI |
+| POST | `/api/v1/admin_booking_requests/expire` | **unwired** — an ops one-shot; the scheduler does this unattended |
+
+### Phantom — 5
+
+The client's real `clientApi.ts` calls five routes the server does not expose. Both groups are
+**deliberately written real-shaped** so flipping the seam is one line once they ship.
+
+| Client call | REQ | Note |
+| --- | --- | --- |
+| `GET /api/v1/admin_roles/list_roles` | REQ-031 | **Deferred** in refinement-phase-3. The admin sub-role vocabulary and phone-OTP admins are seeded, not managed |
+| `POST /api/v1/admin_roles/grant_role` | REQ-031 | |
+| `POST /api/v1/admin_roles/revoke_role` | REQ-031 | |
+| `GET /api/v1/admin_users/search` | **REQ-061 — never filed** | Backs `UserPicker`/`NursePicker` |
+| `POST /api/v1/admin_users/lookup` | **REQ-061 — never filed** | Batch id→label resolve for `AuditLogRow` |
+
+> **REQ-061 does not exist in the ledger.** `ui-phase-11-report.md` records "REQ-061…064 appended", but
+> only 062/063/064 were. Ten live client files cite REQ-061 for the admin user directory. Phase 4 must
+> file it rather than assume it is tracked.
+
+## Two live drifts
+
+**1. The client sends `page_size`; these endpoints declare `PageSize`.** `admin/apis/clientApi.ts`'s
+`pageQuery()` builds `page` + `page_size` "per b1 api-conventions". Model binding is case-*insensitive*,
+not separator-insensitive, so `page_size` does **not** bind to `PageSize` — every admin list would
+silently fall back to the server's default page size. Invisible today because the mock is primary; it
+becomes a real defect the moment `USE_ADMIN_MOCK` flips. See
+[../api-contract.md](../api-contract.md#pagination).
+
+**2. `updatedAt`/`updatedBy` and the audit filters *are* on the wire.** REQ-029 (config audit fields) and
+REQ-030 (`actorId`/`action`/`from`/`to` filters on `audit/get_audit_trail`) were both **delivered** in
+refinement-phase-3. `admin/constants.ts` still gives them as reasons the mock is primary. The only
+remaining reason is REQ-031 + REQ-061.
+
+## Shape rules the JSON does not express
+
+- **Platform config is rows read at compute time**, never hardcoded, and **a rate change is never
+ retroactive** — the effective rate is snapshotted onto the row when the amount is computed.
+- `platform_fee_rate` and `vat_rate` are **rates in `[0, 1)`** — the console validates the closed-open
+ interval before writing (`RATE_CONFIG_KEYS` in `admin/constants.ts`). The canonical values are
+ `0.15` fee / `0.10` VAT (refinement-phase-3).
+- The audit trail's `changedFieldsJson` is a **string containing JSON**, not an object:
+ `{"Field": {"old": …, "new": …}}`. The client parses it defensively and yields `null` on malformed input.
+- `POST update_platform_config` and the holiday/alert writes go through self-committing facades that call
+ `SaveChanges` on the shared scoped context — they run **after** the handler's own `CommitAsync`.
+- Holidays drive **payout date shifting**: the server resolves a bank-closure-safe payout date from this
+ calendar and the client never computes one.
+
+## Enums
+
+| Vocabulary | Values |
+| --- | --- |
+| `ConfigDataType` | `string` `int` `decimal` `bool` `json` |
+| `AuditAction` | `created` `updated` `deleted` |
+| `HolidayType` | `official` `religious` `national` |
+| `SupportAlertType` | `low_rating` `evv_no_show` `evv_location_mismatch` `verification_expired` `shared_sim` `payment_anomaly` `fraud_signal` `nurse_clawback` `emergency` |
+| `SupportAlertSeverity` | `low` `medium` `high` |
+| `SupportAlertStatus` | `open` `assigned` `resolved` |
+| `AdminRole` *(phantom surface)* | `super_admin` `admin` `support` `finance` `moderation` |
+| `DirectoryUserRole` *(phantom surface)* | `customer` `nurse` `admin` `partner` |
+
+The last two describe the REQ-031/REQ-061 shapes and are **not on the wire**.
+
+## Open REQs
+
+| REQ | Status | Effect |
+| --- | --- | --- |
+| REQ-031 | deferred | No RBAC console. Admin roles are seeded |
+| **REQ-061** | **never filed** | No admin user directory. `AuditLogRow` shows `#id` instead of a name |
diff --git a/docs/integration/domains/auth.md b/docs/integration/domains/auth.md
new file mode 100644
index 0000000..9c6392c
--- /dev/null
+++ b/docs/integration/domains/auth.md
@@ -0,0 +1,79 @@
+# auth — phone OTP, sessions, `/me`, role selection
+
+> Client seam `client/src/services/auth/` · `USE_AUTH_MOCK = false` (**real**) · 7 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+The only way into the platform. Phone + OTP, no passwords. The transport rules — bearer header, cookie
+storage, silent refresh, rotation, reuse detection — are in
+[../api-contract.md](../api-contract.md#auth); this file is the endpoints and the payload semantics.
+
+## Endpoints
+
+| Method | Path | Auth | Rate limit | Verdict |
+| --- | --- | --- | --- | --- |
+| POST | `/api/v1/auth/request_otp` | **anonymous** | `otp` 5/min | wired |
+| POST | `/api/v1/auth/verify_otp` | **anonymous** | `otp` 5/min | wired |
+| POST | `/api/v1/auth/refresh` | **anonymous** | `auth` 10/min | wired · called by both `services/auth` **and** the fetch layer |
+| POST | `/api/v1/auth/logout` | `[Authorize]` | — | wired |
+| GET | `/api/v1/me` | `[Authorize]` | — | wired |
+| POST | `/api/v1/me/select_role` | `[Authorize]` | — | wired |
+| GET | `/api/v1/dev/last_otp/{phone}` | **anonymous** | — | **Development only** — see below |
+
+No phantoms.
+
+`AUTH_API_BASE` is `/api/v1`; the routes above are exactly what the client sends.
+
+## `dev/last_otp` — live on the deployment
+
+`DevController` is registered unconditionally, and the OTP-capture bridge behind it is wired only when
+`IsDevelopment()` **and** the SMS provider is capture-safe (`mock` or `telegram`). The
+`balinyaar.ir` deployment runs `ASPNETCORE_ENVIRONMENT=Development` with `Seams:Sms:Provider = telegram`,
+so **both conditions hold and the endpoint is reachable on `api.balinyaar.ir`.** Anyone who knows a
+registered phone number can read its login code. Recorded in [DEPLOY.md](../../../DEPLOY.md) as the
+deployment's largest exposure; the fix is the environment switch, not a code change.
+
+Selecting a real gateway (`kavenegar`, …) disables the bridge — the OTP must never be logged or captured
+once real SMS ships.
+
+## Shape rules the JSON does not express
+
+- **`RequestOtpResult` carries the code length and expiry** (REQ-002, delivered) so the client sizes the
+ input and runs the countdown from server truth rather than a hardcoded constant.
+- **`verify_otp` failures carry a machine-readable `code`** on the envelope (REQ-003, delivered) —
+ e.g. `otp_locked` — so the client branches on the state instead of matching a message string. This is
+ the `code` field described in [../api-contract.md](../api-contract.md#the-envelope).
+- **`refresh` returns a new pair and retires the old refresh token.** Presenting a retired token is
+ treated as theft: the session is killed, not merely refused. A 401 from `/auth/refresh` is therefore
+ terminal and the client must not retry it.
+- **`/me` is the only source of identity.** The JWE is opaque; the client reads role, gender and
+ profile-completeness from `/me`, never from a decoded claim.
+- **Multi-role users**: `/me` reports the roles the caller holds. `POST /me/select_role` commits to one.
+ REQ-004 was **resolved as a client concern** — the client owns the disambiguation and the "resolved vs.
+ pending" role hydration; no backend change was needed. See
+ [docs/rules/client/auth.md](../../rules/client/auth.md).
+- **`logout` returns an empty envelope** — no `data`. The client awaits the revocation and must not
+ `unwrap()` it.
+- **Phone numbers are encrypted at rest.** Login looks the user up by a deterministic HMAC hash
+ (`users.PhoneHash`, derived from `Seams:FieldEncryption:HashKey`), never by comparing the encrypted
+ column. This is why that key is immutable — see [../config-matrix.md](../config-matrix.md).
+
+## Enums
+
+| Vocabulary | Values |
+| --- | --- |
+| `PublicRole` | `customer` `nurse` |
+| `AdminRole` | `admin` `support` `finance` `moderation` `super_admin` |
+| `Gender` | `male` `female` — **load-bearing**, drives same-gender caregiver matching |
+| `NurseVerificationStatus` *(as surfaced on `/me`)* | `not_started` `in_progress` `pending_review` `verified` `rejected` |
+
+> The `/me` verification summary uses a **different vocabulary** from the verification domain's own
+> aggregate status (`not_started` `pending` `in_review` `approved` `rejected` `suspended`). They are two
+> read models over the same source of truth, not a drift — but do not treat the strings as
+> interchangeable. See [verification.md](verification.md).
+
+## Open REQs
+
+| REQ | Status | Effect |
+| --- | --- | --- |
+| REQ-038 | open | `/me` carries no signal that the caller administers a partner center, so partner auto-routing cannot be driven off it. See [partner-center.md](partner-center.md) |
+| REQ-039 | open | The OTP SMS template is not WebOTP-conformant, so the browser's one-tap autofill never fires. The client's WebOTP hook ships anyway and degrades silently |
diff --git a/docs/integration/domains/bnpl.md b/docs/integration/domains/bnpl.md
new file mode 100644
index 0000000..bfd2105
--- /dev/null
+++ b/docs/integration/domains/bnpl.md
@@ -0,0 +1,92 @@
+# bnpl — provider-financed installments
+
+> Client seam `client/src/services/bnpl/` · `USE_BNPL_MOCK = true` (**mock is primary**) · 9 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+The second checkout rail. **Balinyaar does not finance anything** — a provider (SnappPay, Digipay, …) pays
+the platform net of its commission and carries the customer's installments itself. Card checkout is
+[payment.md](payment.md).
+
+## Endpoints
+
+| Method | Path | Auth | Verdict |
+| --- | --- | --- | --- |
+| POST | `/api/v1/checkout_bnpl/eligibility` | `[Authorize]` · `sensitive` | wired |
+| POST | `/api/v1/checkout_bnpl/initiate` | `[Authorize]` · `sensitive` | wired · **`Idempotency-Key`** |
+| GET | `/api/v1/checkout_bnpl/{id}` | `[Authorize]` · `sensitive` | wired |
+| GET | `/api/v1/checkout_bnpl/by_request/{bookingRequestId}` | `[Authorize]` · `sensitive` | wired |
+| POST | `/api/v1/webhooks_bnpl/{provider}` | **anonymous** · `webhook` 120/min | server-only — the provider calls it |
+| GET | `/api/v1/admin_bnpl/{id}` | admin · `sensitive` | **unwired** — no console screen |
+| POST | `/api/v1/admin_bnpl/{id}/verify` | admin · `sensitive` | **unwired** |
+| POST | `/api/v1/admin_bnpl/{id}/settle` | admin · `sensitive` | **unwired** |
+| POST | `/api/v1/admin_bnpl/{id}/revert` | admin · `sensitive` | **unwired** — the reversal is driven from [refunds.md](refunds.md) instead |
+
+### Phantom — 3
+
+All three are REQ-022's deferred half. Written real-shaped so the swap is one line.
+
+| Client call | REQ | Note |
+| --- | --- | --- |
+| `GET /api/v1/checkout_bnpl/options/{bookingRequestId}` | REQ-022 | The D1/D2 provider + plan list, per-plan monthly / down-payment / total |
+| `GET /api/v1/checkout_bnpl/schedule/{id}` | REQ-022 | The D4 repayment schedule |
+| `GET /api/v1/checkout_bnpl/wallet_installments` | REQ-024 | The D5 wallet installment list |
+
+REQ-022 was **partially** delivered: `balinyaar` was added to the `provider_code` enum; `options` and
+`schedule` were deferred. `BnplEligibilityDto` carries a single `planSummary` + `installmentCount`, not a
+list of plans — which is exactly why the client needs `options`.
+
+## The money shape
+
+Two facts that make BNPL different from card, and both are easy to get wrong:
+
+1. **The card payment is recorded net of the provider's fee.** The provider deducts its commission before
+ remitting, so the platform receives `orderAmount − bnplCommission`. `settledAmountIrr` and
+ `bnplCommissionIrr` are both on `BnplOrderStatusDto`, and the handler reads the **actual deducted
+ amount from the settlement response** — never a rate from config. `Seams:Bnpl:CommissionRate` tunes the
+ *mock* only.
+2. **Settlement is not necessarily instant.** `settledAt` is nullable, modelling the deferred / T+1–3 /
+ weekly reality. A null `settledAt` on a `settled` order is normal, not an inconsistency.
+
+`BnplStatus` is **forward-only**. A reversal is `reverted`, with `revertTransactionId`,
+`revertedAmountIrr`, `revertedAt` and — when the provider returns it — `providerCommissionReversedAmount`,
+which the reconciliation needs and which most providers do not send. See [refunds.md](refunds.md) for the
+`bnpl_revert` refund channel.
+
+## Shape rules the JSON does not express
+
+- **Eligibility accepts the credit-check inputs** `{ nationalId, mobile, consent }` (REQ-023, delivered).
+ Consent is **required** when the KYC inquiry runs — it is a legal precondition, not a checkbox.
+- **`eligibilityStatus` distinguishes three outcomes**, and the third is not a failure:
+ `not_eligible` (provider declined) vs `ceiling_exceeded` (order above `creditCeilingIrr` — offer card
+ instead) vs `eligible`. The UI must fall back to card, not show an error, on either negative.
+- **`bookingId` is on the settled order** (REQ-024, confirmed) so the wallet can link an installment plan
+ to its booking.
+- **D5 installment status is provider-reported, not ledger-derived.** The platform does not track the
+ customer's repayment; whatever the provider says is the truth. Never compute an installment state from
+ Balinyaar's own ledger.
+- **`currency` is on the wire and matters.** `Seams:Bnpl:WireCurrency` is `IRR` by default; SnappPay and
+ Digipay speak Rial. Conversion happens **only** inside the adapter via `ICurrencyNormalizer`.
+- Provider credentials proper live in the encrypted `payment_gateways.config_json`; only non-secret
+ connection facts (base URL, sandbox flag, merchant handle) come from `Seams:Bnpl:Providers`.
+- `Seams:Bnpl:NotEligibleMobile` (`09120000099`) is the designated test mobile that returns
+ `not_eligible`, so the fall-back-to-card path is testable.
+
+## Enums
+
+| Vocabulary | Values |
+| --- | --- |
+| `BnplStatus` | `eligible` `token_issued` `verified` `settled` `reverted` `cancelled` `failed` |
+| `BnplEligibilityStatus` | `eligible` `not_eligible` `ceiling_exceeded` |
+| `ProviderCode` | `snapppay` `digipay` `tara` `torobpay` `balinyaar` |
+| `BnplInstallmentStatus` *(D5, provider-reported)* | `paid` `due_soon` `upcoming` `overdue` |
+| `BnplHandoffOutcome` *(client, from the return URL)* | `success` `failure` |
+
+The first three are verified identical to `Entities/Bnpl/BnplStatus.cs`,
+`BnplEligibilityStatus.cs` and `BnplProviderCodes.cs`. Note `snapppay` has **three** `p`s.
+
+## Open REQs
+
+| REQ | Status | Effect |
+| --- | --- | --- |
+| REQ-022 | partially delivered | `options` and `schedule` deferred → 3 phantom routes. D1/D2/D4 are mock-only |
+| REQ-024 | partially delivered | `bookingId` confirmed present; the wallet installment list is deferred |
diff --git a/docs/integration/domains/booking-requests.md b/docs/integration/domains/booking-requests.md
new file mode 100644
index 0000000..c3ef62b
--- /dev/null
+++ b/docs/integration/domains/booking-requests.md
@@ -0,0 +1,104 @@
+# booking-requests — the money-free pre-payment request
+
+> Client seam `client/src/services/bookingRequests/` · `USE_BOOKING_REQUESTS_MOCK = false` (**real**) · 7 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+Stage one of the booking flow: the customer asks, the nurse answers, and **no money exists yet**. A
+`booking_requests` row becomes a `bookings` row only on payment capture — see
+[bookings.md](bookings.md) and [payment.md](payment.md).
+
+> `Features/Booking` (singular, this domain) and `Features/Bookings` (plural, the post-payment engine) are
+> **different server areas, not a rename.**
+
+## Endpoints
+
+| Method | Path | Caller | Verdict |
+| --- | --- | --- | --- |
+| POST | `/api/v1/booking_requests/create` | customer | wired |
+| GET | `/api/v1/booking_requests/list` | both | wired · paginated · role-scoped |
+| GET | `/api/v1/booking_requests/get/{id}` | both | wired |
+| POST | `/api/v1/booking_requests/accept/{id}` | nurse | wired |
+| POST | `/api/v1/booking_requests/reject/{id}` | nurse | wired |
+| POST | `/api/v1/booking_requests/cancel/{id}` | customer | wired |
+| GET | `/api/v1/booking_requests/checkout_summary/{id}` | customer | wired — but read by the **[payment](payment.md)** domain, not this one |
+
+All `[Authorize]`. No phantoms.
+
+`checkout_summary` is the C6 money read (gross / commission / VAT breakdown, REQ-016 delivered). It lives
+on this controller because the request is what gets paid for, and is documented in
+[payment.md](payment.md) where it is consumed.
+
+## The lifecycle
+
+```
+pending_nurse_response ──accept──▸ accepted_awaiting_payment ──capture──▸ converted
+ │ │
+ ├──reject──▸ rejected_by_nurse └──window lapses──▸ payment_deadline_expired
+ ├──deadline──▸ expired_no_response
+ └──customer──▸ cancelled_by_customer
+```
+
+**Forward-only.** A backward or sideways transition is a clean `409`, never a 500. Two deadlines are
+server-owned and config-driven (`booking_request_response_deadline_minutes`,
+`payment_window_minutes` in [admin.md](admin.md)):
+
+- the nurse's response window → `expired_no_response`
+- the customer's payment window after acceptance → `payment_deadline_expired`
+
+`POST /api/v1/admin_booking_requests/expire` is the ops one-shot for both; the in-process scheduler runs
+it unattended. See [admin.md](admin.md).
+
+## Shape rules the JSON does not express
+
+- **Stage-one disclosure is deliberately partial.** Before payment the nurse sees only unencrypted
+ `customerNotes` and a **city/district-coarse masked address**. Encrypted care instructions and the full
+ address are unreadable until the booking is confirmed. This is a hard server rule, not a UI choice.
+- **The countdown is server-frozen.** `CountdownTimer` renders a deadline the server sent; the client
+ never computes an expiry from a local clock.
+- **`variantPrice` is on `BookingRequestDto`** (REQ-013, delivered) so the customer sees the price they are
+ committing to without a second variant fetch. It is a **digit string**.
+- **The inbox list item carries `variantLabel` + `patientAge`** (REQ-014, delivered).
+
+### The two list shapes, exactly
+
+Confirmed field-by-field against the live swagger, because three in-repo comments disagree about this:
+
+| | `BookingRequestListItemDto` (list) | `BookingRequestDto` (detail) |
+| --- | --- | --- |
+| `variantLabel` | **yes** | yes |
+| `patientAge` | **yes** | — (`patientName` instead) |
+| `variantPrice` · `variantPriceUnit` | **no** | yes |
+| address / notes | `customerNotes` only | full masked address block |
+| `nurseRejectionReason` | — | yes, **free text** |
+
+Two consequences:
+
+- **`client/src/services/bookingRequests/types.ts` is behind the wire.** It marks `variantLabel` as
+ "client-augmented … `undefined` on the real path", but the server serves it. Widening the client type is
+ safe and would let the real inbox card render its decision-first headline today.
+- **REQ-050 is partly stale.** It states the list DTO carries neither field, "confirmed against
+ `services/bookingRequests/types.ts`" — i.e. confirmed against the client type, not the wire. What the
+ wire genuinely lacks is `variantPrice`/`variantPriceUnit` on the *list* row and the `status=answered`
+ group filter.
+- **`requiredCaregiverGender` is never defaulted or dropped.** `any` is an explicit choice, distinct from
+ absent.
+- The address the request references is snapshotted at create time; later edits to the saved address do
+ not rewrite it.
+
+## Enums
+
+| Vocabulary | Values |
+| --- | --- |
+| `BookingRequestStatus` | `pending_nurse_response` `accepted_awaiting_payment` `converted` `rejected_by_nurse` `expired_no_response` `payment_deadline_expired` `cancelled_by_customer` |
+| `RequiredCaregiverGender` | `male` `female` `any` |
+| `RequestRole` *(client-side list filter)* | `customer` `nurse` |
+
+Verified identical to `Baya.Domain/Entities/Booking/BookingRequestStatus.cs`. REQ-015 confirmed these
+serialise as the exact snake_case codes.
+
+## Open REQs
+
+| REQ | Status | Effect |
+| --- | --- | --- |
+| REQ-044 | open | No structured `nurseRejectionReasonCode` — the wire carries free-text `nurseRejectionReason`. The client runs a keyword heuristic over it, documented in-code as a known approximation |
+| REQ-050 | open, **narrower than filed** | The list row already has `variantLabel`; it lacks `variantPrice`/`variantPriceUnit`. The `status=answered` group filter is genuinely absent, so the «پاسخداده» tab fires three page-1 queries and concatenates — an unpaged workaround a nurse with many answered requests will hit |
diff --git a/docs/integration/domains/bookings.md b/docs/integration/domains/bookings.md
new file mode 100644
index 0000000..9be5297
--- /dev/null
+++ b/docs/integration/domains/bookings.md
@@ -0,0 +1,97 @@
+# bookings — the post-payment engine, sessions and EVV
+
+> Client seam `client/src/services/bookings/` · `USE_BOOKINGS_MOCK = false` (**real**) · 16 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+Stage two: a paid booking, its per-visit sessions, and electronic visit verification. A `bookings` row is
+created **only on payment capture**, from an accepted [booking request](booking-requests.md).
+
+## Endpoints
+
+| Method | Path | Caller | Verdict |
+| --- | --- | --- | --- |
+| GET | `/api/v1/bookings/list` | both | wired · paginated · `role=customer\|nurse\|all` |
+| GET | `/api/v1/bookings/get/{id}` | both | wired |
+| GET | `/api/v1/bookings/care_instructions/{id}` | nurse, admin | wired · **stage-2 disclosure gate** |
+| POST | `/api/v1/bookings/submit_care_instructions/{id}` | customer | **unwired** — the client has no care-instructions form |
+| POST | `/api/v1/bookings/convert` | — | **unwired** — Development-only capture simulator; the PSP webhook supersedes it |
+| POST | `/api/v1/bookings/transition/{id}` | admin | **unwired** — a raw state-machine escape hatch, no UI |
+| POST | `/api/v1/bookings/cancel/{id}` | — | **unwired, superseded** by `{id}/cancel` |
+| POST | `/api/v1/bookings/{id}/cancel` | customer | wired — by **[refunds](refunds.md)** (cancel *and* refund, REQ-019) |
+| GET | `/api/v1/bookings/{id}/cancellation_policy` | customer | wired — by **[refunds](refunds.md)** (REQ-020) |
+| GET | `/api/v1/booking_sessions/today` | nurse | wired · paginated |
+| GET | `/api/v1/booking_sessions/evv/{id}` | nurse | wired |
+| POST | `/api/v1/booking_sessions/check_in/{id}` | nurse | wired |
+| POST | `/api/v1/booking_sessions/check_out/{id}` | nurse | wired · `sensitive` 20/min — **this is what releases the payout clock** |
+| POST | `/api/v1/booking_sessions/cancel/{id}` | nurse | **unwired** — no per-session cancel in the UI |
+| GET | `/api/v1/admin_evv/list` | admin | **unwired** — no console screen; the mock demonstrates it |
+| POST | `/api/v1/admin_evv/detect_no_shows` | admin | **unwired** — an ops one-shot; the scheduler runs it |
+
+All `[Authorize]`; the two `admin_evv` routes are `DynamicPermission` + `sensitive`. No phantoms.
+
+> **Two cancel routes exist on the same controller.** `POST bookings/cancel/{id}` (action-style, b9) and
+> `POST bookings/{id}/cancel` (REST-style, b11 cancel-and-refund). Only the second is wired. They are
+> not aliases — the second also drives the refund. Treat the first as legacy.
+
+## The lifecycle
+
+```
+pending_payment ──capture──▸ confirmed ──first check-in──▸ in_progress
+ │ │
+ │ all sessions out
+ ▼ ▼
+ cancelled ◂──cancel── completed ──dispute window──▸ closed
+ └──dispute──▸ disputed
+```
+
+Forward-only, through the transition table. `status` has a private setter; only cohesive domain methods
+mutate it and the handler pre-checks, returning a clean `409`.
+
+Sessions run their own machine: `scheduled → in_progress → completed`, or `missed` / `cancelled`.
+
+## Shape rules the JSON does not express
+
+- **Two-stage clinical disclosure is enforced server-side.** `care_instructions/{id}` decrypts and returns
+ the care plan **only** post-confirmation and **only** to the assigned nurse or an admin. It is never
+ projected into a list and never logged. The client's UI gate mirrors this; it does not create it.
+- **EVV is advisory, and `checkInAddressMatch` is a tri-state**: `true` (inside tolerance), `false`
+ (outside), `null` (**no reading** — permission denied or unavailable). Null is not a failure. A
+ mismatch does not block check-in; it raises a support alert
+ (`evv_location_mismatch`, see [admin.md](admin.md)). Tolerance is
+ `evv_location_tolerance_meters` config. REQ-015 confirmed the tri-state.
+- **`BookingDetailDto.variantSnapshotJson` and `addressSnapshotJson` are strings containing JSON**, not
+ typed objects — the point of a snapshot is that later edits to the variant or address cannot rewrite
+ history. The client parses defensively across multiple key spellings (REQ-045 open).
+- **The three-amount split is guaranteed by a DB CHECK**:
+ `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`. `platformFeeRate` is **snapshotted onto
+ the row** at compute time, so a later rate change is not retroactive. Never recompute any of these.
+- **`payoutEligibleAt` per session** is the payout clock, started by check-out and resolved against the
+ holiday calendar server-side. See [payouts.md](payouts.md).
+- **`disputeWindowEndsAt`** gates `completed → closed`; `dispute_window_hours` is config.
+- `BookingListItemDto` is deliberately thin: `id, status, counterpartyName, scheduledDate, sessionCount,
+ amountIrr, disputeWindowEndsAt, createdAt`. **No `patientId`** — which is what blocks REQ-057's care
+ teaser.
+- `NEXT_PUBLIC_EVV_MOCK_GPS` overrides the GPS reading for local testing (`off` = real capture). See
+ [../config-matrix.md](../config-matrix.md).
+
+## Enums
+
+| Vocabulary | Values |
+| --- | --- |
+| `BookingStatus` | `pending_payment` `confirmed` `in_progress` `completed` `disputed` `closed` `cancelled` |
+| `BookingSessionStatus` | `scheduled` `in_progress` `completed` `missed` `cancelled` |
+| `VisitVerificationStatus` (`evvStatus`) | `pending` `checked_in` `completed` |
+| `BookingListRole` *(query param)* | `customer` `nurse` `all` |
+| `EvvGpsMode` *(client test knob)* | `off` `in_range` `out_of_range` `denied` |
+
+Verified identical to `Baya.Domain/Entities/Booking/*.cs`.
+
+## Open REQs
+
+| REQ | Status | Effect |
+| --- | --- | --- |
+| REQ-045 | open | `variantSnapshot`/`addressSnapshot` are untyped JSON strings. The client keeps a defensive multi-key parse; no user-facing defect |
+| REQ-051 | open | The nurse view of a confirmed+ booking still masks the address. The nurse sees a quiet fallback note, not a crash |
+| REQ-052 | open | The today feed carries no service label — it renders patient name + visit index only |
+| REQ-054 | deferred | No web push for new requests; a 15 s poll remains the only signal |
+| REQ-057 | open | `BookingListItemDto` has no `patientId` and the patient read has no `lastVisitAt`, so the card renders no care teaser. See [patients.md](patients.md) |
diff --git a/docs/integration/domains/catalog.md b/docs/integration/domains/catalog.md
new file mode 100644
index 0000000..498612b
--- /dev/null
+++ b/docs/integration/domains/catalog.md
@@ -0,0 +1,63 @@
+# catalog — service categories, option groups, nurse pricing variants
+
+> Client seam `client/src/services/catalog/` · `USE_CATALOG_MOCK = false` (**real**) · 14 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+An EAV catalogue: admin defines categories and their option groups; each nurse composes **variants** —
+a category + a chosen set of option values + a price. A variant is what a customer actually books.
+
+## Endpoints
+
+| Method | Path | Auth | Verdict |
+| --- | --- | --- | --- |
+| GET | `/api/v1/catalog/categories` | **anonymous** | wired · paginated |
+| GET | `/api/v1/catalog/option_groups` | **anonymous** | wired |
+| GET | `/api/v1/nurse_variants/list` | `[Authorize]` | wired · paginated |
+| GET | `/api/v1/nurse_variants/get/{id}` | **anonymous** | wired |
+| POST | `/api/v1/nurse_variants/create` | `[Authorize]` | wired |
+| POST | `/api/v1/nurse_variants/update/{id}` | `[Authorize]` | wired |
+| POST | `/api/v1/nurse_variants/set_active/{id}` | `[Authorize]` | wired |
+| POST | `/api/v1/admin_catalog/create_category` | admin | **unwired** — no console screen |
+| POST | `/api/v1/admin_catalog/update_category/{id}` | admin | **unwired** |
+| POST | `/api/v1/admin_catalog/set_category_active/{id}` | admin | **unwired** |
+| POST | `/api/v1/admin_catalog/create_option_group` | admin | **unwired** |
+| POST | `/api/v1/admin_catalog/update_option_group/{id}` | admin | **unwired** |
+| POST | `/api/v1/admin_catalog/create_option_value` | admin | **unwired** |
+| POST | `/api/v1/admin_catalog/update_option_value/{id}` | admin | **unwired** |
+
+No phantoms. The seven `admin_catalog` routes are `DynamicPermission` + `sensitive`; the catalogue is
+seeded and managed out of band today, so the console has no editor. That is a UI gap, not a contract gap.
+
+> Mutations are **action-style, not REST**: `POST admin_catalog/create_category`, never
+> `POST admin/catalog/categories`. The old contract doc calls this out explicitly and it still holds.
+
+## Shape rules the JSON does not express
+
+- **`GET nurse_variants/get/{id}` is anonymous** while `list` requires auth. That asymmetry is deliberate —
+ a public nurse profile links to a specific variant.
+- **A duplicate variant is rejected by `option_set_hash`.** The server hashes the chosen option-value set
+ per (nurse, category) and enforces uniqueness, so a nurse cannot list the same configuration twice at two
+ prices. The client surfaces the resulting `409`, it does not pre-check.
+- **The variant snapshot is serialised at booking time** (`IVariantSnapshotSerializer`) onto the booking
+ row — see [bookings.md](bookings.md). Editing or deactivating a variant never changes a past booking.
+- **`set_active` is the only way to retire a variant.** There is no delete; a variant referenced by
+ bookings must remain resolvable.
+- **Prices are IRR digit strings** outbound. The nurse enters Toman in the UI and the client converts at
+ the input boundary — the wire is always IRR.
+- Reference names come as **both** `nameFa` and `nameEn`; the client picks by locale. `OptionGroupDto`
+ carries its `values` inline, so the variant builder needs one round trip, not one per group.
+- `isRequired` + `sortOrder` on an option group drive the builder's validation and layout — the client
+ does not hardcode either.
+
+## Enums
+
+| Vocabulary | Values |
+| --- | --- |
+| `PriceUnit` | `per_hour` `per_session` `per_half_day` `per_day` `per_24h` |
+
+`PriceUnit` is a label vocabulary, never a multiplier — the client must not derive a total from it. Display
+strings are i18n keys, never the code.
+
+## Open REQs
+
+None. The variant builder (b7) and the Home category grid (A5) both read the contract as served.
diff --git a/docs/integration/domains/geography.md b/docs/integration/domains/geography.md
new file mode 100644
index 0000000..1d0dd11
--- /dev/null
+++ b/docs/integration/domains/geography.md
@@ -0,0 +1,64 @@
+# geography — provinces, cities, districts
+
+> Client seam `client/src/services/geography/` · `USE_GEOGRAPHY_MOCK = false` (**real**) · 13 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+The reference hierarchy every address, service area and search filter is keyed on. Also the home of the
+single most load-bearing null in the schema.
+
+## Endpoints
+
+| Method | Path | Auth | Verdict |
+| --- | --- | --- | --- |
+| GET | `/api/v1/geo/provinces` | **anonymous** | wired |
+| GET | `/api/v1/geo/cities` | **anonymous** | wired |
+| GET | `/api/v1/geo/districts` | **anonymous** | wired |
+| GET | `/api/v1/geo/tree` | **anonymous** | **unwired** — the client fetches the three levels separately and caches each |
+| POST | `/api/v1/admin_geo/create_province` | admin | **unwired** — no console screen |
+| POST | `/api/v1/admin_geo/update_province/{id}` | admin | **unwired** |
+| POST | `/api/v1/admin_geo/set_province_active/{id}` | admin | **unwired** |
+| POST | `/api/v1/admin_geo/create_city` | admin | **unwired** |
+| POST | `/api/v1/admin_geo/update_city/{id}` | admin | **unwired** |
+| POST | `/api/v1/admin_geo/set_city_active/{id}` | admin | **unwired** |
+| POST | `/api/v1/admin_geo/create_district` | admin | **unwired** |
+| POST | `/api/v1/admin_geo/update_district/{id}` | admin | **unwired** |
+| POST | `/api/v1/admin_geo/set_district_active/{id}` | admin | **unwired** |
+
+No phantoms. The nine `admin_geo` routes are `DynamicPermission` + `sensitive`; geography is seeded, so
+there is no console editor. Mutations are **action-style** (`admin_geo/create_city`, never
+`admin_geo/cities`).
+
+## `districtId = null` means whole-city
+
+This is the one rule to get right, and it reads in **both** directions:
+
+- **On a nurse service area** (see [service-areas.md](service-areas.md)), `districtId = null` means the
+ nurse covers the **entire city**, not "no district".
+- **On a search query**, a customer in district *D* must match both a nurse whose area names *D* and a
+ nurse whose area is whole-city. See [search.md](search.md).
+- **On an address** it is genuinely optional metadata — a missing district does not widen anything.
+
+Never coerce the null to 0 or to a sentinel id, and never write a query that drops whole-city rows.
+
+## Shape rules the JSON does not express
+
+- Every level returns **both** `nameFa` and `nameEn`; the client picks by locale.
+- `isActive` filters the pickers. An inactive city must stay **resolvable** — an existing address or
+ booking references it — so it is filtered from selection, never deleted.
+- **Two Neshan keys exist and they are different products.** The server's geocoder key is
+ `Seams:Geocoding:ApiKey` (server-side address→point); the client's map key is
+ `NEXT_PUBLIC_NESHAN_KEY` (a *web* key for the embeddable map/search). Never share one value between
+ them. With the client key unset, `AddressMapPicker` falls back to a bounded-canvas grid, which is why
+ dev, CI and jsdom all work without it. See [../config-matrix.md](../config-matrix.md).
+- Geocoding is a seam: `Seams:Geocoding:Provider` = `mock` (default) or `neshan`. The mock resolves a
+ deterministic point near the city centroid; an address containing `NO_GEO` resolves to null coordinates
+ so the "saved without a map pin" state is testable per-request.
+
+## Enums
+
+None. All three levels are integer ids with localised names.
+
+## Open REQs
+
+None. REQ-008 (accept the client-picked pin) and REQ-009 (`provinceId` on the address DTO) were delivered
+and are documented in [addresses.md](addresses.md).
diff --git a/docs/integration/domains/index.md b/docs/integration/domains/index.md
new file mode 100644
index 0000000..a75b70e
--- /dev/null
+++ b/docs/integration/domains/index.md
@@ -0,0 +1,121 @@
+# Domain contracts
+
+One file per client `services/` domain — **22 files, 22 domains, one-to-one.** Each names every server
+endpoint that belongs to it, verdicted against the live swagger and the real client code.
+
+> Last verified: 2026-07-30 against commit `d3ec723` and
+> [`../openapi/swagger.v1.json`](../openapi/swagger.v1.json) (2026-07-29).
+
+Read [`../api-contract.md`](../api-contract.md) first — the envelope, casing, pagination, errors, auth,
+idempotency and money rules hold everywhere and are not restated per domain.
+
+---
+
+## How to read a domain file
+
+Every endpoint carries one verdict:
+
+| Verdict | Means |
+| --- | --- |
+| **wired** | In swagger **and** called by the domain's real `apis/clientApi.ts` |
+| **unwired** | In swagger, no real client caller. Server-only, admin-only, or superseded — the reason is given |
+| **phantom** | The client calls it; **the server has no such route.** It 404s. Always carries its REQ |
+
+`phantom` rows are the frontend's proposed routes, filed as REQs and mocked behind the domain seam
+meanwhile. They are not bugs in the client — they are the contract's open edge — but on a domain whose
+mock is **off** they are live 404s, and that is called out where it happens.
+
+## The census
+
+186 operations, each in exactly one file below. 184 belong to a domain; 2 (`ping`) are platform
+endpoints and live in [`../api-contract.md`](../api-contract.md#platform-endpoints-outside-every-domain).
+
+| Domain file | `client/src/services/` | Seam | Server ops | Phantom |
+| --- | --- | --- | --- | --- |
+| [addresses.md](addresses.md) | `addresses` | real | 5 | — |
+| [admin.md](admin.md) | `admin` | **mock** | 14 | 5 |
+| [auth.md](auth.md) | `auth` | real | 7 | — |
+| [bnpl.md](bnpl.md) | `bnpl` | **mock** | 9 | 3 |
+| [booking-requests.md](booking-requests.md) | `bookingRequests` | real | 7 | — |
+| [bookings.md](bookings.md) | `bookings` | real | 16 | — |
+| [catalog.md](catalog.md) | `catalog` | real | 14 | — |
+| [geography.md](geography.md) | `geography` | real | 13 | — |
+| [notifications.md](notifications.md) | `notifications` | real | 4 | — |
+| [nurse.md](nurse.md) | `nurse` | real | 4 | — |
+| [partner-center.md](partner-center.md) | `partnerCenter` | **mock** | 9 | 6 |
+| [patient-records.md](patient-records.md) | `patientRecords` | **mock** | 5 | — |
+| [patients.md](patients.md) | `patients` | real | 5 | — |
+| [payment.md](payment.md) | `payment` | real | 3 | 1 |
+| [payouts.md](payouts.md) | `payouts` | **mock** | 13 | 1 |
+| [profiles.md](profiles.md) | `profiles` | real | 7 | — |
+| [refunds.md](refunds.md) | `refunds` | **mock** | 8 | 4 |
+| [reviews.md](reviews.md) | `reviews` | real | 8 | — |
+| [search.md](search.md) | `search` | real | 2 | — |
+| [service-areas.md](service-areas.md) | `serviceAreas` | real | 3 | — |
+| [tickets.md](tickets.md) | `tickets` | real | 11 | 1 |
+| [verification.md](verification.md) | `verification` | **mock** | 17 | 3 |
+| | | | **184** | **24** |
+
+"Seam" is the domain's `constants.ts` flag (`USE__MOCK`): **15 real, 7 mock.** A mocked domain
+still has a complete real client — flipping the flag is one line in `apis/index.ts`.
+
+**Two phantoms sit on a domain whose mock is off**, so they are reachable and they 404:
+`GET /api/v1/bookings/payment_history` ([payment.md](payment.md), REQ-047) and
+`POST /api/v1/tickets/{id}/assign` ([tickets.md](tickets.md), REQ-063). Both are guarded in the client —
+the first renders an empty state, the second is behind a default-off capability flag.
+
+## Route-shape exceptions
+
+The routing convention is snake_case segments generated from `[controller]`/`[action]` tokens. **Four
+controllers hardcode a route string instead**, and three of those introduce hyphens:
+
+| Route | Controller | Note |
+| --- | --- | --- |
+| `api/v1/admin/partner-centers` | `AdminPartnerCentersController` | hyphens **and** a nested `admin/` segment; children add `/set-active`, `/sponsor-nurse` |
+| `api/v1/admin/tickets` | `AdminTicketsController` | nested `admin/` segment |
+| `api/v1/admin/reviews/moderation_queue` | `AdminReviewsController` | nested `admin/`, then snake_case |
+| `api/v1/internal/bookings/{bookingId}/center` | `InternalCentersController` | an `internal/` namespace |
+
+Every other admin controller uses a flat `admin_*` prefix (`admin_geo`, `admin_catalog`, `admin_refunds`,
+…). The split is historical, not meaningful. Since the route also derives the dynamic-permission key,
+normalising it is a breaking change to permissions as well as URLs — it is recorded here, not fixed.
+
+## Enum vocabularies
+
+Swagger declares **no** string enums (see
+[`../api-contract.md`](../api-contract.md#enums)), so each domain file carries its own vocabulary. Every
+one was cross-checked against the server's `Baya.Domain` code set **and** the client's string-literal
+union. All match except one, noted in [tickets.md](tickets.md).
+
+| Vocabulary | Domain file | Server source |
+| --- | --- | --- |
+| `BookingRequestStatus` · `RequiredCaregiverGender` | [booking-requests.md](booking-requests.md) | `Entities/Booking/BookingRequestStatus.cs` |
+| `BookingStatus` · `BookingSessionStatus` · `VisitVerificationStatus` | [bookings.md](bookings.md) | `Entities/Booking/*.cs` |
+| `PriceUnit` | [catalog.md](catalog.md) | catalog config rows |
+| `BnplStatus` · `BnplEligibilityStatus` · `ProviderCode` | [bnpl.md](bnpl.md) | `Entities/Bnpl/*.cs` |
+| `PaymentTransactionStatus` · `MoadianStatus` | [payment.md](payment.md) | `Entities/Payments/`, `Entities/Invoices/` |
+| `RefundStatus` · `RefundChannel` · `ClawbackStatus` · cancellation codes | [refunds.md](refunds.md) | `Entities/Refunds/*.cs` |
+| `PayoutStatus` · `PayoutBatchStatus` · `EarningsState` | [payouts.md](payouts.md) | `Entities/Payouts/*.cs` |
+| `VerificationStatus` · `VerificationStepStatus` · `StepTypeCode` | [verification.md](verification.md) | `Entities/Verification/*.cs` |
+| `ModerationStatus` · `ModerationAction` | [reviews.md](reviews.md) | `Entities/Reviews/ReviewModerationStatus.cs` |
+| `TicketStatus` · `TicketCategory` · `TicketAuthorRole` | [tickets.md](tickets.md) | `Entities/Messaging/TicketCodes.cs` |
+| `CenterOnboardingState` | [partner-center.md](partner-center.md) | `Entities/PartnerCenters/` |
+| `BankAccountStatus` | [nurse.md](nurse.md) | bank-account entity |
+| roles · `Gender` | [auth.md](auth.md) | identity seed |
+| config/audit/holiday/alert codes | [admin.md](admin.md) | `Entities/Configuration/`, `Audit/`, `Holidays/`, `SupportAlerts/` |
+
+## What replaced what
+
+These files supersede [`dev/contracts/domains/`](../../../dev/contracts/domains/) — 17 hand-written files
+frozen 2026-07-13, plus the two `conventions/` files. Route-level content there held up well: an audit of
+every route those files name found **zero** that the live swagger lacks. What did not hold up:
+
+| Was | Now |
+| --- | --- |
+| `conventions/api-conventions.md`: body casing is "typically `snake_case` … derive from swagger" | **camelCase**, proven mechanically. [`../api-contract.md`](../api-contract.md#casing) |
+| `conventions/api-conventions.md`: server default `https://localhost:5002` | `http://localhost:5002` — plain HTTP (contradiction **C-3**) |
+| The envelope has 5 fields | It has **6** — `code` was added for machine-readable errors (REQ-003) |
+| Enum vocabularies spread across 17 files and the REQ ledger | One vocabulary block per domain file, cross-checked both ways |
+| `messaging.md` (851 B, headerless) silently amending `messaging-notifications-admin.md` | Merged: [tickets.md](tickets.md) + [notifications.md](notifications.md) + [admin.md](admin.md) (contradiction **C-8**) |
+| 17 files whose names matched *backend phases* | 22 files whose names match the **client's domains**, which is how the seam is actually consumed |
+| The REQ ledger as the change log you had to read to know the current shape | Each domain file states the current shape and lists only its **open** REQs |
diff --git a/docs/integration/domains/notifications.md b/docs/integration/domains/notifications.md
new file mode 100644
index 0000000..7e71794
--- /dev/null
+++ b/docs/integration/domains/notifications.md
@@ -0,0 +1,48 @@
+# notifications — the in-app feed and unread badge
+
+> Client seam `client/src/services/notifications/` · `USE_NOTIFICATIONS_MOCK = false` (**real**) · 4 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+Server-raised, user-scoped notifications. In-app only — there is no push channel and no email channel.
+
+## Endpoints
+
+| Method | Path | Verdict |
+| --- | --- | --- |
+| GET | `/api/v1/notifications/get_notifications` | wired · paginated |
+| GET | `/api/v1/notifications/get_unread_count` | wired — polled for the bell badge |
+| POST | `/api/v1/notifications/mark_notification_read` | wired |
+| POST | `/api/v1/notifications/mark_all_read` | wired |
+
+All `[Authorize]`. No phantoms. The domain maps 1:1.
+
+## Shape rules the JSON does not express
+
+- **`dataJson` is a string containing JSON**, not an object. It is the deep-link payload: the client parses
+ it (`parse.ts`) and resolves a route from it (`deepLink.ts`), both with unit tests, and **falls back to
+ an inert notification rather than throwing** on anything unrecognised. A notification whose payload
+ cannot be parsed still renders — it just is not tappable.
+- **Unread count is a separate read, not derived from the list.** The badge must be correct without
+ fetching a page, so `get_unread_count` is its own cheap query.
+- **`mark_all_read` is a bulk write**; the client invalidates both the list and the count keys, and does not
+ patch items locally.
+- The feed is **day-grouped in the UI** with Shamsi headers — a client-side transform over UTC
+ `createdAt`. The server sends no grouping.
+- Notifications are raised through a self-committing facade (`DispatchAsync`) that runs **after** the
+ originating handler's `CommitAsync` — so a notification never exists for a transaction that rolled back.
+
+## Enums
+
+`type` is a **bare string** on the wire and the vocabulary is open-ended by design — new server-side
+notification types must not break an older client. The client models the *payload* as a discriminated
+union (`NotificationData`, keyed on a `kind` inside `dataJson`) and treats an unknown `type` as
+non-actionable rather than an error.
+
+Consequence for the server: **adding a notification type is safe; changing an existing type's `dataJson`
+shape is not.** The deep-link parser keys off the payload, not the type string.
+
+## Open REQs
+
+| REQ | Status | Effect |
+| --- | --- | --- |
+| REQ-054 | deferred, non-blocking | No web push for new booking requests. The nurse dashboard's 15 s poll remains the only signal. Nothing was built for it |
diff --git a/docs/integration/domains/nurse.md b/docs/integration/domains/nurse.md
new file mode 100644
index 0000000..750c0ca
--- /dev/null
+++ b/docs/integration/domains/nurse.md
@@ -0,0 +1,53 @@
+# nurse — nurse bank accounts
+
+> Client seam `client/src/services/nurse/` · `USE_NURSE_BANK_MOCK = false` (**real**) · 4 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+The nurse's payout destination. Narrow domain, high stakes: a payout cannot be paid to an unverified IBAN.
+
+> The domain is named `nurse`, not `nurse-bank-accounts`, because that is the client folder name. The
+> nurse's *profile* lives in [profiles.md](profiles.md); coverage in
+> [service-areas.md](service-areas.md); verification in [verification.md](verification.md).
+
+## Endpoints
+
+| Method | Path | Rate limit | Verdict |
+| --- | --- | --- | --- |
+| GET | `/api/v1/nurse_bank_accounts/list` | — | wired |
+| POST | `/api/v1/nurse_bank_accounts/add` | `sensitive` 20/min | wired |
+| POST | `/api/v1/nurse_bank_accounts/set_primary/{id}` | — | wired |
+| POST | `/api/v1/nurse_bank_accounts/verify_ownership/{id}` | `sensitive` 20/min | wired |
+
+All `[Authorize]`. No phantoms. The domain maps 1:1.
+
+## Shape rules the JSON does not express
+
+- **`iban_hash` is UNIQUE across the platform.** The same IBAN cannot be registered by two nurses; the
+ second `add` returns a `409`. The uniqueness is enforced on a deterministic hash, not the encrypted
+ column.
+- **The IBAN is encrypted at rest and returned masked** — `maskedIban` on every read model, including the
+ admin-side `PayoutDto` and the nurse's own `NursePayoutHistoryDto`. **The full IBAN is never returned
+ after the write that created it.** Write-then-masked is the pattern.
+- **`verify_ownership` is استعلام شبا** — a Shahkar-class inquiry that confirms the account holder's
+ national id matches the nurse's. It is a seam: `Seams:BankOwnership:Provider` = `mock` (default) or
+ `finnotech`. The mock returns a match for every IBAN **except** `Seams:BankOwnership:MismatchIban`
+ (`IR000000000000000000000000`), which exists so the payout-gating path is testable.
+- **Ownership verification gates payouts, and the gate lives in the payout engine, not here.**
+ `EligibleNurseEarningsDto.hasVerifiedPrimaryIban` is the flag the admin console reads before generating
+ a batch — see [payouts.md](payouts.md). A nurse with earnings and no verified primary IBAN accrues a
+ balance and is simply not paid.
+- Client-side IBAN handling (`iban.ts`) does checksum validation and formatting only. It is a UX
+ affordance; the server re-validates.
+
+## Enums
+
+| Vocabulary | Values |
+| --- | --- |
+| `BankAccountStatus` | `pending` `verified` `mismatch` |
+
+`mismatch` is a terminal, actionable state — the holder's national id did not match — and is distinct from
+`pending`, which only means the inquiry has not run.
+
+## Open REQs
+
+None.
diff --git a/docs/integration/domains/partner-center.md b/docs/integration/domains/partner-center.md
new file mode 100644
index 0000000..b3ec47a
--- /dev/null
+++ b/docs/integration/domains/partner-center.md
@@ -0,0 +1,99 @@
+# partner-center — nursing companies
+
+> Client seam `client/src/services/partnerCenter/` · `USE_PARTNER_MOCK = true` (**mock is primary**) · 9 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+Nursing companies («مراکز») that sponsor nurses onto the platform. The domain with the **widest gap between
+what the client wants and what the server serves** — 6 of its client calls are phantom.
+
+## Endpoints
+
+| Method | Path | Verdict |
+| --- | --- | --- |
+| GET | `/api/v1/admin/partner-centers` | wired · paginated |
+| POST | `/api/v1/admin/partner-centers` | wired |
+| GET | `/api/v1/admin/partner-centers/{id}` | wired |
+| PATCH | `/api/v1/admin/partner-centers/{id}` | wired |
+| POST | `/api/v1/admin/partner-centers/{id}/set-active` | wired — REQ-032's delivered half |
+| POST | `/api/v1/admin/partner-centers/{id}/sponsor-nurse` | wired |
+| POST | `/api/v1/admin/partner-centers/{id}/verify` | wired |
+| GET | `/api/v1/centers/{id}/dashboard` | **unwired** — the client wants `centers/me/*` splits instead |
+| GET | `/api/v1/internal/bookings/{bookingId}/center` | **unwired** — the MoR resolver, server-internal |
+
+The seven `admin/partner-centers` routes are `DynamicPermission` + `sensitive`; `centers` is `[Authorize]`;
+`internal/bookings` is `DynamicPermission`.
+
+This domain owns **three of the four route-shape exceptions** in the API: hyphens (`partner-centers`,
+`set-active`, `sponsor-nurse`), a nested `admin/` segment, and an `internal/` namespace. It also has one of
+only two `PATCH` verbs. See [index.md](index.md#route-shape-exceptions).
+
+### Phantom — 6
+
+| Client call | REQ | Note |
+| --- | --- | --- |
+| `GET /api/v1/centers/me` | REQ-032 | The portal's own-center read |
+| `GET /api/v1/centers/me/nurses` | REQ-032 | Split read — the server serves one aggregate instead |
+| `GET /api/v1/centers/me/bookings` | REQ-032 | Split read |
+| `GET /api/v1/centers/me/bookings/{id}` | REQ-064 | Partner-scoped booking detail |
+| `GET /api/v1/centers/me/settlement` | REQ-033 | Per-booking commission invoices |
+| `GET /api/v1/admin/partner-centers/{id}/nurses` | REQ-032 | The admin-side sponsored-nurse list |
+
+**The shape mismatch is the point.** The server serves **one aggregate**, `GET centers/{id}/dashboard` →
+`CenterDashboardDto` with `sponsoredNurses` inline. The portal wants **`/me` plus paginated splits** — it
+cannot page an inline array, and it does not know its own center id without REQ-038. Until REQ-032 lands,
+the portal is mock-only.
+
+## Merchant of record
+
+The one business rule that changes where money goes:
+
+- **`isMerchantOfRecord = true`** → the *center* is the seller. It invoices the customer, holds the
+ commercial relationship, and Balinyaar's cut is a commission **against the center**.
+- **`isMerchantOfRecord = false`** → the nurse is the seller and the center is a sponsor only.
+
+`GET internal/bookings/{bookingId}/center` is the **MoR resolver** the invoice pipeline calls to decide
+which entity issues the invoice — which is why `InvoiceDto.issuingEntityType` exists. See
+[payment.md](payment.md). `commissionRate` on the center is a **per-center override** of the platform
+default, snapshotted at compute time like every other rate.
+
+## Shape rules the JSON does not express
+
+- **The settlement IBAN is write-then-masked.** `settlementIbanMasked` is the only form on every read model;
+ the full value never comes back after the write that set it. Same pattern as
+ [nurse.md](nurse.md).
+- **`verify` is a licence check behind a seam.** `ILicenseVerificationService` checks the eNamad code and the
+ MoH establishment permit; by default it returns `NeedsManualReview`, so `verify` records a **human admin
+ decision**. `Seams:LicenseVerification:AutoApprove` makes the mock return `Valid` to test the
+ auto-approve path. A real eNamad/MoH registry adapter ignores the knob.
+- **`technicalDirectorNurseUserId` links to a real verified nurse**, not a free-text name — Iranian
+ regulation requires a named technical director («مدیر فنی») with a valid licence.
+- **`set-active` is suspend/activate, not delete.** A suspended center's sponsored nurses and past bookings
+ stay resolvable.
+- `sponsoredNurseCount` is denormalised onto both the list item and the detail so the queue needs no
+ per-row count.
+- `legalEntityType` and `mohEstablishmentPermitNo` are the regulatory identity; `enamadCode` is the
+ e-commerce trust seal. All three are distinct and none substitutes for another.
+
+## Enums
+
+| Vocabulary | Values |
+| --- | --- |
+| `CenterOnboardingState` | `draft` `pending_verification` `verified` `suspended` |
+| `MoadianStatus` *(on the center's invoices)* | `pending` `submitted` `registered` `failed` |
+
+`MoadianStatus` is shared with [payment.md](payment.md) — it is the سامانه مودیان submission state, and it
+is the same vocabulary on both sides.
+
+> `CenterOnboardingState` is the client's model of the center's position in onboarding. On the wire the
+> server carries the **facts** it is derived from — `isActive` and `verifiedAt` on both
+> `PartnerCenterListItemDto` and `PartnerCenterDetailDto` — not the state string itself. Derive, do not
+> expect a field.
+
+## Open REQs
+
+| REQ | Status | Effect |
+| --- | --- | --- |
+| REQ-032 | partially delivered | `set-active` landed. The `/me` split reads and the IBAN write-then-masked flow are deferred → 5 phantom routes. **The main reason this seam is mocked** |
+| REQ-033 | partially delivered | `totalIrr` is on `InvoiceDto`; the per-booking commission invoice list is deferred → 1 phantom |
+| REQ-064 | open | No partner-scoped booking detail → 1 phantom |
+| REQ-038 | open | `/me` carries no signal that the caller administers a center, so the portal cannot auto-route or discover its own center id. See [auth.md](auth.md) |
diff --git a/docs/integration/domains/patient-records.md b/docs/integration/domains/patient-records.md
new file mode 100644
index 0000000..0989c17
--- /dev/null
+++ b/docs/integration/domains/patient-records.md
@@ -0,0 +1,83 @@
+# patient-records — the care plan and visit records
+
+> Client seam `client/src/services/patientRecords/` · `USE_PATIENT_RECORDS_MOCK = true` (**mock is primary**) · 5 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+The clinical content: a family-owned **care plan** (medications, routine, tasks) and the **append-only**
+visit records nurses write against it. The patient rows themselves are [patients.md](patients.md).
+
+## Endpoints
+
+| Method | Path | Verdict |
+| --- | --- | --- |
+| GET | `/api/v1/patients/{patientId}/care_record` | wired — the care plan |
+| PUT | `/api/v1/patients/{patientId}/care_record` | wired — **the only `PUT` in the API** |
+| GET | `/api/v1/patients/{patientId}/care_records` | wired · paginated (**`page`/`pageSize`**) — visit history |
+| POST | `/api/v1/patients/{patientId}/care_records` | wired — a nurse writes one visit record |
+| GET | `/api/v1/patients/{patientId}/record_access` | wired — **ask before you read** |
+
+All `[Authorize]`. **No phantoms — every route the client calls exists.** The seam is mocked for UI
+completeness, not because the contract is missing.
+
+> Singular vs. plural is load-bearing here: `care_record` (no `s`) is the **plan**; `care_records` is the
+> **visit history**. They are different resources on adjacent paths.
+
+## Two different ownership models on one path
+
+| | `care_record` (plan) | `care_records` (visits) |
+| --- | --- | --- |
+| Owner | the **family** (the customer) | the **nurse** who performed the visit |
+| Write | `PUT` — upsert, replaces | `POST` — **append only** |
+| Edit after the fact | yes, it is a living plan | **no** |
+| Delete | no | **no** |
+
+**A nurse can never edit or delete a visit record.** It is a clinical record: append-only is the whole
+point, and there is no endpoint that would allow otherwise. Do not add one.
+
+## `record_access` — check before you read
+
+`GET record_access` answers "may this caller read this patient's records, and if not, why". The client
+calls it **first** and renders the denial state rather than firing a read and interpreting an error.
+
+Two reasons come back, and they are deliberately hard to tell apart from outside:
+
+| `RecordAccessDeniedReason` | Means |
+| --- | --- |
+| `no_access` | The patient exists; you are not authorised |
+| `not_found` | No such patient — **or** a tenancy mismatch |
+
+That second row is the platform's tenancy rule: a row you do not own is a **404, never a 403**, because a
+403 confirms it exists. See [../api-contract.md](../api-contract.md#status-codes).
+
+## Shape rules the JSON does not express
+
+- **Every record body is encrypted at rest** and decrypted only for an authorised caller. Care content is
+ **never** projected into a list, never logged, and never included in a search index.
+- **Nurse read access is scoped by an active booking**, not by having ever cared for the patient. The
+ two-stage clinical disclosure rule applies: full care content is readable only post-confirmation, only by
+ the assigned nurse and admin. See [bookings.md](bookings.md).
+- **`CarePlanDto` is `{ patientId, medications, routine, tasks }`** — three structured lists, not free text,
+ so the client can render a schedule and a checklist rather than a blob.
+- **`CareRecordDto.taskResults` is structured** (REQ-027, delivered): each visit reports per-task outcomes
+ against the plan's tasks, which is what lets the family see whether the routine was actually followed.
+- **`nurseName` is on the visit record** so the history is attributable without a per-row lookup.
+- Dose units, frequency presets and times-of-day are **codes**; the UI labels are i18n keys. Never render
+ the code, and never parse a frequency into a schedule client-side.
+
+## Enums
+
+| Vocabulary | Values |
+| --- | --- |
+| `DoseUnit` | `tablet` `capsule` `drop` `cc` `unit` |
+| `FrequencyPreset` | `once_daily` `twice_daily` `three_times_daily` `every_8_hours` `as_needed` |
+| `TimeOfDayCode` | `morning` `noon` `evening` `night` |
+| `RecordAccessDeniedReason` | `no_access` `not_found` |
+| `CareRecordTab` *(client UI only)* | `medications` `routine` `history` `tasks` |
+
+`as_needed` (PRN) has **no** time-of-day and must not be rendered on a schedule grid.
+
+## Open REQs
+
+| REQ | Status | Effect |
+| --- | --- | --- |
+| REQ-027 | delivered | The family-owned care record (medications/routine/tasks), `record_access`, and structured task results are all served |
diff --git a/docs/integration/domains/patients.md b/docs/integration/domains/patients.md
new file mode 100644
index 0000000..4a38086
--- /dev/null
+++ b/docs/integration/domains/patients.md
@@ -0,0 +1,52 @@
+# patients — the care circle
+
+> Client seam `client/src/services/patients/` · `USE_PATIENTS_MOCK = false` (**real**) · 5 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+The people a customer books care *for* — «حلقهٔ مراقبت» in the UI. A patient is owned by the customer who
+created them, never by a nurse. Clinical content about a patient lives in
+[patient-records.md](patient-records.md).
+
+## Endpoints
+
+| Method | Path | Verdict |
+| --- | --- | --- |
+| GET | `/api/v1/patients/list` | wired · paginated (`Page`/`PageSize`) |
+| GET | `/api/v1/patients/get/{id}` | wired |
+| POST | `/api/v1/patients/create` | wired |
+| POST | `/api/v1/patients/update/{id}` | wired |
+| POST | `/api/v1/patients/archive/{id}` | wired — **archive, not delete** |
+
+All `[Authorize]`. No phantoms. The domain maps 1:1.
+
+## Shape rules the JSON does not express
+
+- **`relation` and `conditions` are on `PatientDto`** (REQ-005, delivered). `relation` is the family
+ relationship shown on the record sheet; `conditions` is the coarse condition list used for triage.
+- **`gender` is load-bearing.** It drives same-gender caregiver matching, which is a near-hard requirement
+ in this market. Never defaulted, never dropped, never inferred from a name.
+- **`initialMedicalNotes` is encrypted at rest** and readable only by the owning customer (and, post-
+ confirmation, the assigned nurse via the booking's care-instructions read — see
+ [bookings.md](bookings.md)). It is not the care record.
+- **Archive, never delete.** A patient referenced by a booking must stay resolvable; `isActive = false`
+ removes them from pickers. There is no delete endpoint and there should not be.
+- **`displayName` is server-composed** from first/last name. The client renders `displayName` and uses the
+ parts only in the edit form — so a naming-convention change is a server change, not a client one.
+- `birthDate` is a date; the client derives the age band (`age.ts`) for display. The **server** stamps
+ `patientAge` on the nurse-facing booking-request list row (see
+ [booking-requests.md](booking-requests.md)) — the nurse never receives a birth date.
+
+## Enums
+
+| Vocabulary | Values |
+| --- | --- |
+| `Gender` | `male` `female` |
+
+`bloodType` is a free string, not an enum.
+
+## Open REQs
+
+| REQ | Status | Effect |
+| --- | --- | --- |
+| REQ-057 | open | `PatientDto` has no `lastVisitAt` (or `visitCount`), and `BookingListItemDto` has no `patientId`, so the booking card can render no care teaser. Either field unblocks it |
+| REQ-058 | deferred, non-blocking | No patient photo upload. `PatientDto` has no `avatarUrl` and no UI reads one — `InitialsAvatar` ships either way |
diff --git a/docs/integration/domains/payment.md b/docs/integration/domains/payment.md
new file mode 100644
index 0000000..5ce4838
--- /dev/null
+++ b/docs/integration/domains/payment.md
@@ -0,0 +1,95 @@
+# payment — card checkout, the PSP webhook, invoices
+
+> Client seam `client/src/services/payment/` · `USE_PAYMENT_MOCK = false` (**real**) · 3 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+The card money path. Three endpoints on the wire; the domain reads three more that belong to neighbours.
+Installment checkout is [bnpl.md](bnpl.md); reversals are [refunds.md](refunds.md).
+
+## Endpoints
+
+| Method | Path | Auth | Verdict |
+| --- | --- | --- | --- |
+| POST | `/api/v1/bookings/{bookingRequestId}/payments` | `[Authorize]` · `sensitive` 20/min | wired · **`Idempotency-Key`** |
+| GET | `/api/v1/invoices/{bookingId}` | `[Authorize]` | wired |
+| POST | `/api/v1/webhooks/payments/{provider}` | **anonymous** · `webhook` 120/min | server-only — the PSP calls it |
+
+Also read by this domain, documented with their owners:
+`GET booking_requests/checkout_summary/{id}` and `GET booking_requests/get/{id}`
+([booking-requests.md](booking-requests.md)).
+
+### Phantom — 1
+
+| Client call | REQ | Live? |
+| --- | --- | --- |
+| `GET /api/v1/bookings/payment_history` | REQ-047 | **Yes — this domain's mock is off.** The wallet «پرداختها» tab calls it, gets a 404, and renders its empty state. Guarded, but a real 404 on every visit |
+
+## The flow, and why it is shaped this way
+
+```
+accepted request ──initiate──▸ PSP hosted page ──customer pays──▸ PSP webhook
+ (money-free) (redirectUrl) │
+ ▼
+ server re-verifies, then creates + confirms the booking
+```
+
+Four rules that follow, and they are the whole design:
+
+1. **Payment is initiated against the accepted *request*, not a booking.** The `bookings` row does not
+ exist yet. `POST bookings/{bookingRequestId}/payments` takes a **request** id despite the `bookings/`
+ prefix — the route is misleading and the parameter name is the truth.
+2. **There is no client verify endpoint.** The server re-verifies with the acquirer *inside* the webhook
+ handler. A client-reported "success" is never trusted.
+3. **The client learns the outcome by polling.** `getPaymentOutcome` maps the request status
+ (`converted` → succeeded) with backoff. A first-class transaction-status read is REQ-017's remaining
+ half.
+4. **One `Idempotency-Key` per attempt**, reused across retries of that attempt; a new attempt takes a new
+ key. A `409` on initiate means "already in progress / already captured" — a benign convergence, and the
+ client must not surface it as an error.
+
+**Webhook idempotency does not use the header.** The handler upserts the provider event first, keyed on
+`external_event_id`, and no-ops on a duplicate; `bookings.booking_request_id` is `UNIQUE` so a replay
+cannot create a second booking; a unique-violation on confirm is treated as idempotent success. The DB
+constraint is the backstop, not the handler's `if`.
+
+`POST bookings/convert` ([bookings.md](bookings.md)) is the **Development-only** capture simulator that
+stands in for the webhook locally. It is fail-closed outside Development/Testing.
+
+## Shape rules the JSON does not express
+
+- **`CheckoutSummaryDto` serves the money breakdown so the client never derives it** (REQ-016, delivered):
+ `serviceCostIrr`, `commissionIrr`, `vatIrr`, `vatRate`, `totalIrr` **and** the three-amount split
+ `grossPriceIrr` / `balinyaarCommissionIrr` / `nursePayoutAmount`. All digit strings.
+- **VAT is on Balinyaar's commission only** — the platform's taxable supply — never on the nurse payout.
+- **`InitiatePaymentResult` is `{ transactionId, redirectUrl, gatewayReferenceCode }`.** The client hands
+ off to `redirectUrl` and keeps `transactionId` to poll.
+- **`InvoiceDto` carries `totalIrr`** (REQ-033, partial) = platform commission + BNPL commission + VAT, and
+ `moadianStatus`/`moadianReferenceNumber` for the سامانه مودیان e-invoicing submission.
+ `issuingEntityType` distinguishes a platform-issued invoice from a partner-center one — see
+ [partner-center.md](partner-center.md).
+- **The acquirer is a seam.** `Seams:Payments:Provider` = `mock` (default) / `zarinpal` / `sadad` /
+ `vandar` / `jibit`; `IPaymentProvider`, `ISettlementSplitProvider` and `IWebhookVerifier` swap together.
+ Webhook signature secrets are per-provider (`Seams:Payments:WebhookSigningSecrets`), read from the
+ `X-Signature` header by default. A provider with no signature falls back to the mandatory server-side
+ re-verify.
+
+## Enums
+
+| Vocabulary | Values |
+| --- | --- |
+| `PaymentTransactionStatus` | `pending` `succeeded` `failed` |
+| `MoadianStatus` | `pending` `submitted` `registered` `failed` |
+| `GatewayReturnOutcome` *(client-side, from the return URL)* | `success` `failure` |
+
+Verified against `Entities/Payments/PaymentTransactionStatus.cs` and `Entities/Invoices/MoadianStatus.cs`.
+`GatewayReturnOutcome` is a client reading of the acquirer's redirect and is **advisory only** — the
+authoritative outcome is the polled request status.
+
+## Open REQs
+
+| REQ | Status | Effect |
+| --- | --- | --- |
+| REQ-017 | delivered (partial in practice) | `bookingId` is on the converted request; the first-class transaction-status read is not, so the client polls the request status instead |
+| REQ-046 | open | No nurse identity on the checkout summary and no client-readable payment reference. The real receipt hides the identity avatar/badge and the tracking line |
+| REQ-047 | open | No customer payment-transactions list. **Live 404** on `bookings/payment_history`; the wallet tab renders empty |
+| REQ-049 | open | `InvoiceDto` has no payment method, transaction reference, or seller fiscal identity. The invoice renders the money breakdown and مودیان status unconditionally |
diff --git a/docs/integration/domains/payouts.md b/docs/integration/domains/payouts.md
new file mode 100644
index 0000000..929025b
--- /dev/null
+++ b/docs/integration/domains/payouts.md
@@ -0,0 +1,102 @@
+# payouts — weekly nurse settlement
+
+> Client seam `client/src/services/payouts/` · `USE_PAYOUTS_MOCK = true` (**mock is primary**) · 13 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+Money going out, weekly, in batches. The nurse's view is read-only; the admin's view is the one place in
+the platform where an irreversible transfer is triggered by hand.
+
+## Endpoints
+
+| Method | Path | Auth | Verdict |
+| --- | --- | --- | --- |
+| GET | `/api/v1/nurse_payouts/earnings` | `[Authorize]` | wired · paginated — per-booking earnings |
+| GET | `/api/v1/nurse_payouts/earnings_balance` | `[Authorize]` | wired — the four-bucket balance |
+| GET | `/api/v1/nurse_payouts/history` | `[Authorize]` | wired · paginated |
+| GET | `/api/v1/nurse_payouts/{id}` | `[Authorize]` | wired — payout detail |
+| GET | `/api/v1/nurses/{nurseId}/payable_balance` | `[Authorize]` | **unwired** — the nurse reads `earnings_balance` instead |
+| GET | `/api/v1/admin_payouts/eligible` | admin · `sensitive` | wired · paginated |
+| GET | `/api/v1/admin_payouts/batches` | admin · `sensitive` | wired · paginated |
+| POST | `/api/v1/admin_payouts/batches` | admin · `sensitive` | **unwired** — generation is automatic (below) |
+| GET | `/api/v1/admin_payouts/batches/{id}` | admin · `sensitive` | wired · paginated (**`page`/`pageSize`**) |
+| POST | `/api/v1/admin_payouts/batches/{id}/process` | admin · `sensitive` | **unwired** — the irreversible step; no console button yet |
+| POST | `/api/v1/admin_payouts/{payoutId}/retry` | admin · `sensitive` | wired |
+| POST | `/api/v1/admin_payouts/{payoutId}/mark_failed` | admin · `sensitive` | **unwired** |
+| POST | `/api/v1/webhooks/payouts/{provider}` | **anonymous** · `webhook` | server-only — the transferor's reconciliation callback |
+
+This domain absorbed the **entire** wire-level drift between the 2026-07-13 contract freeze and the
+2026-07-29 snapshot — one added endpoint and one changed schema, both here:
+
+- **Added (C-6):** `POST /api/v1/webhooks/payouts/{provider}` — the transferor's reconciliation callback.
+- **Changed (C-7):** `GeneratePayoutBatchCommand` gained **`systemInitiated: boolean`**, alongside the
+ existing `periodStart`/`periodEnd` dates. That is the refinement-phase-7 scheduler flag: it distinguishes
+ a batch the recurring job generated from one an admin generated, which is what keeps the
+ "generation is automatic, processing is not" rule auditable.
+
+### Phantom — 1
+
+| Client call | REQ | Note |
+| --- | --- | --- |
+| `POST /api/v1/admin_payouts/{id}/transfer_reference` | REQ-036 | Deferred. The reference is *readable* on `PayoutDto.transferReference`; what is missing is a route to record one manually |
+
+## Generation is automatic; processing is not
+
+This is a hard platform rule, not a convention:
+
+- A **scheduled job may generate** a `draft` batch. `IRecurringJob` + `RecurringJobSchedulerHostedService`
+ do this weekly, in-process (refinement-phase-7).
+- The **irreversible `process` step is always an explicit admin action.** No job, no schedule, no retry
+ loop may trigger a real transfer.
+
+Which is why `POST batches` is unwired (the job does it) and `POST batches/{id}/process` is unwired (the
+console has no button yet — that is the gap, and it is deliberate that nothing automated fills it).
+
+## The money rules
+
+- **`UNIQUE` on booking id: one payout per booking, ever.** The DB constraint is the authority; the handler
+ does not rely on an `if`.
+- **The net balance is SIGNED and must never be clamped to zero.** A nurse with a clawback larger than
+ their eligible earnings has a **negative** `netAmountIrr`. Rendering it as 0 tells them they have nothing
+ owing when in fact they owe. The client displays the signed value.
+- **Clawback netting is whole-clawback greedy**, never partial: a clawback either fits in this batch or
+ waits for the next. See [refunds.md](refunds.md).
+- **Payout dates are resolved against the bank-holiday calendar server-side.** The client never computes
+ one — see [admin.md](admin.md). `nurse_payout_interval_days` and `payout_satna_threshold_irr` are config;
+ the threshold selects PAYA vs SATNA.
+- **A verified primary IBAN gates payment, not accrual.** `EligibleNurseEarningsDto.hasVerifiedPrimaryIban`
+ is what the console checks; a nurse without one accrues a balance and is not paid. See
+ [nurse.md](nurse.md).
+- The IBAN is **always masked** on every read model, nurse-side and admin-side alike.
+
+## A resolved drift
+
+`payouts/apis/clientApi.ts` states that `NursePayoutHistoryDto` "carries **no** `failureReason` — that field
+lives on the admin-only `PayoutDto` … until REQ-025 adds it". **The live wire has it**: `failureReason` is
+on `NursePayoutHistoryDto` *and* `NursePayoutDetailDto` *and* `PayoutDto`. The comment predates the
+delivery; a `failed` payout can show its reason to the nurse today.
+
+The client also sends `Idempotency-Key` on process/retry. **The server does not read it there** — only
+`PaymentsController` and `CheckoutBnplController` do. Harmless (those writes are idempotent by
+constraint), but the header is decorative on this domain. See
+[../api-contract.md](../api-contract.md#idempotency).
+
+## Enums
+
+| Vocabulary | Values |
+| --- | --- |
+| `PayoutStatus` | `pending` `submitted` `paid` `failed` |
+| `PayoutBatchStatus` | `draft` `processing` `partially_failed` `completed` `failed` |
+| `EarningsState` | `pending` `eligible` `paid` `clawback_applied` |
+
+`PayoutStatus` and `PayoutBatchStatus` are verified identical to `Entities/Payouts/*.cs` and are
+forward-only through `PayoutStatusTransitions`. `partially_failed` is a real batch outcome — some
+destinations settled, some did not — and drives the single-payout `retry`; `Seams:BankTransfer:FailIban`
+exists to make it testable.
+
+## Open REQs
+
+| REQ | Status | Effect |
+| --- | --- | --- |
+| REQ-025 | delivered | The four-bucket balance, per-booking earnings list and payout detail are all served (including `failureReason`, above) |
+| REQ-036 | deferred | No single-payout preview, no `holidayShifted` flag, no record-transfer-reference route → 1 phantom |
+| REQ-053 | open | No payout forecast (next batch date + expected eligible amount). The nurse dashboard's forecast line renders nothing on the real path |
diff --git a/docs/integration/domains/profiles.md b/docs/integration/domains/profiles.md
new file mode 100644
index 0000000..fdc50ff
--- /dev/null
+++ b/docs/integration/domains/profiles.md
@@ -0,0 +1,57 @@
+# profiles — customer and nurse profiles
+
+> Client seam `client/src/services/profiles/` · `USE_PROFILES_MOCK = false` (**real**) · 7 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+One client domain over two server controllers, because the client's profile screens are one feature with
+two actor variants. Identity and roles come from [auth.md](auth.md)'s `/me`, not from here.
+
+## Endpoints
+
+| Method | Path | Verdict |
+| --- | --- | --- |
+| GET | `/api/v1/customer_profiles/me` | wired |
+| POST | `/api/v1/customer_profiles/upsert` | wired |
+| POST | `/api/v1/customer_profiles/avatar` | **unwired** — the client's avatar upload calls the nurse route only |
+| GET | `/api/v1/nurse_profiles/me` | wired |
+| POST | `/api/v1/nurse_profiles/upsert` | wired |
+| POST | `/api/v1/nurse_profiles/avatar` | wired |
+| POST | `/api/v1/nurse_profiles/set_accepting_bookings` | wired |
+
+All `[Authorize]`. No phantoms.
+
+> **`customer_profiles/avatar` exists and is unused.** The nurse avatar upload is wired; the customer one
+> is not, so a customer cannot set a photo even though the endpoint is live. A one-line client gap, not a
+> contract gap.
+
+## Shape rules the JSON does not express
+
+- **Avatar upload is `multipart/form-data`.** This is the one place the client must **not** set
+ `Content-Type` — `clientFetch` detects a `FormData` body and lets the browser write the multipart
+ boundary. A manual JSON content-type there breaks the upload. See
+ [../api-contract.md](../api-contract.md).
+- **`avatarUrl` is served by the object-storage seam.** `Seams:ObjectStorage:Provider` = `local` (default,
+ writes under `RootPath`) or `s3`. In the deployment `RootPath` is a **named docker volume** — without it,
+ uploads vanish on the next `up --build`. See [../topology.md](../topology.md).
+- **`isAcceptingBookings` is a real toggle with real consequences.** It is one of the four conditions the
+ search index's `is_searchable` requires — flipping it off removes the nurse from discovery. See
+ [search.md](search.md).
+- **`isVerified` on `NurseProfileDto` is derived, never writable.** It is written **only** by the
+ verification finalize transaction when the aggregate reaches `approved`. Never set it from a profile
+ write. See [verification.md](verification.md).
+- **`averageRating` / `totalReviews` / `totalCompletedBookings` are recomputed from source**, not
+ incremented. A moderation change that hides a review recomputes the aggregate. See
+ [reviews.md](reviews.md).
+- **`specializationsJson` is a string containing JSON**, like the other `*Json` fields on this wire.
+- `preferredLanguage` on `CustomerProfileDto` (REQ-007, delivered) alongside the name update.
+- `defaultEmergencyContactName`/`Phone` are the customer-level fallback used when a booking supplies none.
+
+## Enums
+
+None of its own. `educationLevel` and `educationField` are free strings. Gender lives on the user
+(see [auth.md](auth.md)), not on the profile.
+
+## Open REQs
+
+None. REQ-006 (avatar/object-storage upload route) and REQ-007 (name + preferred language) were both
+delivered in refinement-phase-3.
diff --git a/docs/integration/domains/refunds.md b/docs/integration/domains/refunds.md
new file mode 100644
index 0000000..e4dc776
--- /dev/null
+++ b/docs/integration/domains/refunds.md
@@ -0,0 +1,88 @@
+# refunds — cancellation, reversal, clawbacks, invoices
+
+> Client seam `client/src/services/refunds/` · `USE_REFUNDS_MOCK = true` (**mock is primary**) · 8 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+Money going back. The customer half is thin and real; the admin half is largely deferred, which is why the
+seam is mocked.
+
+## Endpoints
+
+| Method | Path | Auth | Verdict |
+| --- | --- | --- | --- |
+| GET | `/api/v1/refunds/by_booking/{bookingId}` | `[Authorize]` | wired — the customer's refund for a booking |
+| GET | `/api/v1/refunds/{id}/status` | `[Authorize]` | wired |
+| GET | `/api/v1/admin_refunds` | admin · `sensitive` | wired · paginated |
+| POST | `/api/v1/admin_refunds` | admin · `sensitive` | **unwired** — creates **and executes** in one call; the client wants preview → approve (REQ-035) |
+| POST | `/api/v1/admin_refunds/{id}/confirm_settlement` | admin · `sensitive` | **unwired** — the manual-channel settlement confirm |
+| POST | `/api/v1/admin_refunds/{id}/mark_failed` | admin · `sensitive` | **unwired** |
+| POST | `/api/v1/admin_clawbacks/{id}/write_off` | admin · `sensitive` | **unwired** — no console screen |
+| POST | `/api/v1/admin_invoices` | admin · `sensitive` | **unwired** — owner-issue an invoice (REQ-018's fallback path) |
+
+Also wired by this domain, documented with their owner ([bookings.md](bookings.md)):
+`POST bookings/{id}/cancel` (cancel **and** refund, REQ-019) and
+`GET bookings/{id}/cancellation_policy` (the pre-cancel preview, REQ-020).
+
+### Phantom — 4
+
+| Client call | REQ | Note |
+| --- | --- | --- |
+| `POST /api/v1/admin_refunds/preview` | REQ-035 | Deferred. Today's single `POST admin_refunds` creates+executes with no preview step |
+| `POST /api/v1/admin_refunds/{id}/approve` | REQ-035 | Deferred |
+| `POST /api/v1/admin_refunds/{id}/reject` | REQ-035 | Deferred |
+| `GET /api/v1/refunds/my` | REQ-048 | The customer's "all my refunds" list. The wallet «استردادها» tab renders empty on the real path |
+
+## The money rules
+
+Four, and none of them are the client's to compute:
+
+1. **A refund is a reversal leg, not a deletion.** `ledger_entries` is append-only; every posting group
+ balances. Nothing is ever edited or removed.
+2. **Fee-leg decomposition is served, not derived.** `RefundStatusDto` carries
+ `platformFeeRefundedIrr` and `nursePayoutRefundedIrr` separately (REQ-021, delivered) — a partial refund
+ does not necessarily refund the commission and the payout in the same proportion. Never split a total.
+3. **Pre-payout and post-payout fork.** If the nurse has not been paid, the payout leg is simply reduced.
+ If they have, the platform raises a **clawback**, which the payout engine nets against the nurse's next
+ batch — whole-clawback greedy netting, never a partial. See [payouts.md](payouts.md).
+4. **VAT is on commission only**, so a refund's VAT leg follows the commission leg, never the payout.
+
+## Shape rules the JSON does not express
+
+- **`refundChannel` decides the mechanics and is not a display detail**: `psp_card` reverses through the
+ acquirer, `bnpl_revert` calls the BNPL provider's revert (see [bnpl.md](bnpl.md)), `manual` is a bank
+ transfer an admin confirms with `confirm_settlement`. Each has a different ETA, and
+ `expectedCustomerRefundEta` is server-computed per channel — the client displays it verbatim.
+- **`CancellationPolicyPreviewDto` is the complete pre-cancel answer** (REQ-020, delivered):
+ `cancellable`, `cancellationPolicyCode`, `refundPercentageApplied`, `feePercentage`, `refundAmountIrr`,
+ `feeAmountIrr`, `refundableAmountIrr`, the two fee legs, `appliesTo`, `leadTimeLabel`, `refundChannel`,
+ `expectedCustomerRefundEta`, and a **per-session** breakdown. The client shows it and asks for
+ confirmation; it computes none of it.
+- **Cancellation tiers are config rows**, not code — `cancellation_tier1/2/3_refund_rate` in
+ [admin.md](admin.md) — and the applied rate is **snapshotted onto the refund** at compute time, so a
+ later tier change is not retroactive.
+- **`refundPercentage` on `CancellationPolicyDto` is a rate, `refundPercentageApplied` on the refund is the
+ snapshot.** They can legitimately differ; that is the point.
+- The crash-window fix in refinement-phase-6 wired the previously unreachable BNPL and manual settlement
+ paths — a refund created against those channels now actually clears.
+
+## Enums
+
+| Vocabulary | Values |
+| --- | --- |
+| `RefundStatus` | `requested` `approved` `processing` `succeeded` `failed` `rejected` |
+| `RefundChannel` | `psp_card` `bnpl_revert` `manual` |
+| `ClawbackStatus` | `pending` `recovered` `written_off` |
+| `CancellationPolicyCode` | `free_24h` `partial_under_24h` `customer_no_show` |
+| `CancellationLeadTime` | `gt_24h` `lt_24h` `started` |
+| `CancellationScope` | `whole_booking` `remaining_sessions` |
+| `CancelReasonCategory` *(client)* | `changed_mind` `schedule_conflict` `found_other_care` `other` |
+
+The first three are verified identical to `Entities/Refunds/RefundStatus.cs` and `ClawbackStatus.cs`.
+
+## Open REQs
+
+| REQ | Status | Effect |
+| --- | --- | --- |
+| REQ-035 | deferred | No admin preview / approve / reject → 3 phantom routes. The one live route creates and executes together, which the console will not call |
+| REQ-048 | open | No customer "all my refunds" list → 1 phantom. The wallet tab renders empty on the real path |
+| REQ-033 | partially delivered | `totalIrr` is on `InvoiceDto`; the partner per-booking commission invoice list is deferred. See [partner-center.md](partner-center.md) |
diff --git a/docs/integration/domains/reviews.md b/docs/integration/domains/reviews.md
new file mode 100644
index 0000000..8952a4c
--- /dev/null
+++ b/docs/integration/domains/reviews.md
@@ -0,0 +1,85 @@
+# reviews — ratings, tags, moderation
+
+> Client seam `client/src/services/reviews/` · `USE_REVIEWS_MOCK = false` (**real**) · 8 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+Customer reviews of a completed booking, pre-screened and then human-moderated before they are public.
+Clinical care records are a different domain — [patient-records.md](patient-records.md).
+
+## Endpoints
+
+| Method | Path | Auth | Verdict |
+| --- | --- | --- | --- |
+| GET | `/api/v1/bookings/{bookingId}/review_eligibility` | `[Authorize]` | wired |
+| GET | `/api/v1/bookings/{bookingId}/my_review` | `[Authorize]` | wired |
+| POST | `/api/v1/bookings/{bookingId}/review` | `[Authorize]` | wired |
+| GET | `/api/v1/nurses/{nurseProfileId}/reviews` | **anonymous** | wired · paginated (**`page`/`pageSize`**) |
+| GET | `/api/v1/nurses/{nurseProfileId}/review_tags` | **anonymous** | **unwired** — the client reads tags off the review rows |
+| PATCH | `/api/v1/reviews/{reviewId}/status` | `[Authorize]` | wired — the moderation decision |
+| POST | `/api/v1/reviews/{reviewId}/tags` | `[Authorize]` | **unwired** — no console screen edits tags |
+| GET | `/api/v1/admin/reviews/moderation_queue` | admin | wired · paginated |
+
+No phantoms. Note `PATCH` — one of only two `PATCH` verbs in the whole API (the other is on
+[partner-center.md](partner-center.md)); everything else mutates with `POST`. Note also the hardcoded
+nested `admin/reviews/` route segment — see [index.md](index.md#route-shape-exceptions).
+
+## Aggregates are recomputed, never incremented
+
+`averageRating`, `totalReviews` and `totalCompletedBookings` on the nurse profile are **recomputed from
+source** whenever a review's moderation status changes. Hiding a published review must lower the average;
+an incrementing counter cannot do that correctly. Never `+= 1` a review aggregate. See
+[profiles.md](profiles.md).
+
+## The moderation gate
+
+```
+submit ──AI pre-screen──▸ pending_moderation ──admin──▸ published
+ │ │
+ banned word hit unpublish ──▸ hidden
+ ▼
+ rejected
+```
+
+- **A review is not public on submit.** `IReviewModerationService` pre-screens; by default clean text
+ returns a human-review **flag**, keeping the gate on. `Seams:ReviewModeration:AutoApproveClean` makes
+ clean text auto-publish; `BannedWords` (default `scam`, `fraud`, `کلاهبردار`) forces `reject`. Both are
+ mock knobs — a real classifier ignores them.
+- **A low rating raises a support alert** (`low_rating`), linked as `lowRatingAlertId` on the queue item.
+ See [admin.md](admin.md).
+- **`unpublish` is a distinct action from `hide`** in the client's `ModerationAction` union even though both
+ land on `hidden` — the audit trail records which was chosen.
+
+## Shape rules the JSON does not express
+
+- **`ReviewEligibilityDto` is `{ canReview, reason }`** and the reason is a **code**, not a message:
+ `not_completed` `already_reviewed` `not_owner` `not_found`. The client maps it to an i18n key. Eligibility
+ is server-decided — the client must not infer it from booking status.
+- **The author is masked** (REQ-026, confirmed): a public review carries `authorMasked`, never a full name.
+ This is a privacy decision, not a display choice.
+- **`tagCodes` is on `ModerationQueueItemDto`** (REQ-037, delivered) so the queue shows what the reviewer
+ tagged without a second fetch.
+- **Review tags are codes; labels are i18n keys.** Never render a tag code, and never build a display
+ string from one.
+- `moderationReason` is admin-facing free text and is **not** returned on the public review read.
+- The public reviews list is **anonymous and paginated with `page`/`pageSize`** (camelCase — unlike
+ `search/nurses`, which uses `page_size`). See [../api-contract.md](../api-contract.md#pagination).
+
+## Enums
+
+| Vocabulary | Values |
+| --- | --- |
+| `ModerationStatus` | `pending_moderation` `published` `hidden` `rejected` |
+| `ModerationAction` | `publish` `hide` `reject` `unpublish` |
+| `ReviewIneligibilityReason` | `not_completed` `already_reviewed` `not_owner` `not_found` |
+
+`ModerationStatus` and `ModerationAction` are both defined in
+`Entities/Reviews/ReviewModerationStatus.cs` and verified identical to the client's unions — the file
+carries the four states and the four actions together.
+
+## Open REQs
+
+| REQ | Status | Effect |
+| --- | --- | --- |
+| REQ-026 | delivered | Eligibility + my-review-for-booking reads, and masked-author confirmation |
+| REQ-037 | delivered | `tagCodes` on the moderation queue item |
+| REQ-040 | open | No `topReviewTag` on the search index row — the C2 card renders without the tag chip. Owned by [search.md](search.md) |
diff --git a/docs/integration/domains/search.md b/docs/integration/domains/search.md
new file mode 100644
index 0000000..5752d7e
--- /dev/null
+++ b/docs/integration/domains/search.md
@@ -0,0 +1,81 @@
+# search — nurse discovery
+
+> Client seam `client/src/services/search/` · `USE_SEARCH_MOCK = false` (**real**) · 2 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+Two endpoints, both anonymous, both reading a **projected index** rather than joining live tables.
+
+## Endpoints
+
+| Method | Path | Auth | Verdict |
+| --- | --- | --- | --- |
+| GET | `/api/v1/search/nurses` | **anonymous** | wired · paginated — **`page` + `page_size`** |
+| GET | `/api/v1/nurses/{nurseId}/profile` | **anonymous** | wired |
+
+No phantoms. `POST /api/v1/admin_search/rebuild_index` is the index rebuild one-shot and lives in
+[admin.md](admin.md); `nurses/{id}/reviews` and `/review_tags` are in [reviews.md](reviews.md);
+`nurses/{id}/trust_badge` is in [verification.md](verification.md).
+
+## The one endpoint with snake_case query params
+
+`GET /api/v1/search/nurses` is the **only** endpoint whose query parameters are snake_case:
+
+```
+service_category_id city_id district_id nurse_gender min_price max_price page page_size
+```
+
+`service_category_id` and `city_id` are required. This matters because it is also the only endpoint where
+paging is declared as **`page_size`**, not `pageSize` — and model binding is case-insensitive, not
+separator-insensitive, so sending `pageSize` here binds nothing and silently yields the default page size.
+The client's `searchClientApi` already sends `page_size` correctly. See
+[../api-contract.md](../api-contract.md#pagination).
+
+## `is_searchable` — the four conditions
+
+A nurse variant appears in results only when **all four** hold. Anything that flips one of them must
+maintain the index in the same unit of work (`ISearchIndexMaintainer`):
+
+1. the nurse's verification aggregate is `approved` — [verification.md](verification.md)
+2. `isAcceptingBookings` is true — [profiles.md](profiles.md)
+3. the variant is active — [catalog.md](catalog.md)
+4. the nurse has at least one service area covering the queried city — [service-areas.md](service-areas.md)
+
+**`districtId = null` means whole-city on both sides of the match.** A customer filtering by district *D*
+must see a nurse whose area names *D* **and** a nurse whose area is whole-city. A query that drops the
+nulls silently hides every whole-city nurse. See [geography.md](geography.md).
+
+## Shape rules the JSON does not express
+
+- **The index row is denormalised** (REQ-012, delivered): `nurseName`, `avatarUrl` and `distanceKm` are on
+ `NurseSearchResultDto`, so the result card needs no per-row fetch. `price` is a **digit string**.
+- **`distanceKm` is nullable** — null when either side has no resolved coordinates. The card omits the
+ distance chip rather than showing 0.
+- **`GET nurses/{id}/profile` aggregates** identity, bio, specialties, the full services list and the
+ latest review into `NursePublicProfileDto` (REQ-012). One request builds the whole profile screen.
+- **`attributeChips` is a server-composed display list**, not a code vocabulary — render it, do not map it.
+- **`inoMembership` on the public profile is a boolean summary** of the INO verification step, not the step
+ itself. The per-step detail is the still-open REQ-043.
+- The index is maintained **inline inside each source write's transaction**, not by a background job — so a
+ profile change is visible in search immediately, and a failed index write fails the source write.
+- `Search:Backend` selects the implementation: unset or `sql` → `SqlNurseSearch`. Any other value **throws
+ at startup** — Elasticsearch is deferred, and the config fails loudly rather than silently degrading.
+
+## Enums
+
+| Vocabulary | Values |
+| --- | --- |
+| `NurseGender` *(filter + result field)* | `male` `female` |
+| `SearchSort` *(client)* | `rating` — the only sort implemented |
+
+`nurse_gender` accepts `male`/`female`; **omit the param for "any"** — there is no `any` value on this
+filter, unlike `requiredCaregiverGender` on a booking request.
+
+## Open REQs
+
+| REQ | Status | Effect |
+| --- | --- | --- |
+| REQ-040 | open | No `topReviewTag` on the index row. The C2 card renders without the tag chip |
+| REQ-041 | open | No free-text `q` over nurse/variant/category names. Discovery is filter-only |
+| REQ-042 | open | `NursePublicProfileDto` has no `nurseGender` — the *index row* has it, the profile does not, so the C3 screen cannot show the gender chip |
+| REQ-066 | open, **narrower than filed** | `search/nurses` is **already anonymous**. What is missing is the rate limit — `SearchController` carries no `[EnableRateLimiting]`, so guest browse falls to the 100/min global per-IP limiter |
+| REQ-067 | open, **narrower than filed** | `nurses/{id}/profile` is **already anonymous**. What is missing is the privacy review of the payload for unauthenticated callers |
diff --git a/docs/integration/domains/service-areas.md b/docs/integration/domains/service-areas.md
new file mode 100644
index 0000000..308f101
--- /dev/null
+++ b/docs/integration/domains/service-areas.md
@@ -0,0 +1,53 @@
+# service-areas — nurse coverage
+
+> Client seam `client/src/services/serviceAreas/` · `USE_SERVICE_AREAS_MOCK = false` (**real**) · 3 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+Where a nurse will travel. Three endpoints, one rule that everything else depends on.
+
+## Endpoints
+
+| Method | Path | Verdict |
+| --- | --- | --- |
+| GET | `/api/v1/nurse_service_areas/list` | wired · paginated |
+| POST | `/api/v1/nurse_service_areas/add` | wired |
+| DELETE | `/api/v1/nurse_service_areas/remove/{id}` | wired |
+
+All `[Authorize]`. No phantoms. The domain maps 1:1. There is no update — coverage is add/remove.
+
+## `districtId = null` means whole-city
+
+A service area is `(cityId, districtId?)`. **`districtId = null` is the affirmative claim "I cover this
+entire city"** — not missing data, not "no district".
+
+The consequences reach three other domains:
+
+- **Search** must match a whole-city area against a district-filtered query, in both directions. A query
+ that drops nulls silently hides every whole-city nurse. See [search.md](search.md).
+- **`is_searchable`** requires at least one area covering the queried city — coverage is one of its four
+ conditions. See [search.md](search.md).
+- **Geography** owns the ids and the same null convention. See [geography.md](geography.md).
+
+The client models this as a **single control**: the coverage editor offers "whole city" as a first-class
+choice alongside individual districts, so a nurse can never accidentally express it as an empty district
+list. Do not reintroduce a two-step "city, then optionally districts" flow — an empty selection is
+ambiguous in a way `null` is not.
+
+## Shape rules the JSON does not express
+
+- **A duplicate area is a `409`.** Adding `(city, null)` when district rows for that city already exist —
+ or the reverse — is a conflict the server resolves; the client surfaces it rather than pre-checking.
+- **Removing the last area for a city removes the nurse from search in that city** in the same
+ transaction, via `ISearchIndexMaintainer`. There is no lag and no reconciliation job.
+- Coverage is independent of `isAcceptingBookings`: a nurse can keep coverage while pausing bookings, and
+ both are conditions of `is_searchable`.
+- `list` is paginated even though the practical row count is small — the platform paginates every
+ unbounded list without exception.
+
+## Enums
+
+None. Both fields are geography ids; `districtId` is nullable and the null is meaningful.
+
+## Open REQs
+
+None. REQ-008/REQ-009 concern addresses, not coverage — see [addresses.md](addresses.md).
diff --git a/docs/integration/domains/tickets.md b/docs/integration/domains/tickets.md
new file mode 100644
index 0000000..749a577
--- /dev/null
+++ b/docs/integration/domains/tickets.md
@@ -0,0 +1,91 @@
+# tickets — coordination, support, emergency
+
+> Client seam `client/src/services/tickets/` · `USE_TICKETS_MOCK = false` (**real**) · 11 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+Threaded messaging between customer, nurse and staff. Also the emergency channel. The in-app notification
+feed is a separate domain — [notifications.md](notifications.md).
+
+## Endpoints
+
+| Method | Path | Verdict |
+| --- | --- | --- |
+| GET | `/api/v1/tickets` | wired · paginated · `bookingId` filter |
+| POST | `/api/v1/tickets` | wired |
+| GET | `/api/v1/tickets/{id}` | wired — **stamps `last_read_at`** |
+| POST | `/api/v1/tickets/{id}/messages` | wired · optional `clientMessageId` |
+| POST | `/api/v1/tickets/{id}/close` | wired |
+| POST | `/api/v1/tickets/{id}/reopen` | wired |
+| POST | `/api/v1/tickets/emergency` | **unwired** — no emergency entry point in the UI yet |
+| POST | `/api/v1/tickets/{id}/participants` | **unwired** |
+| DELETE | `/api/v1/tickets/{id}/participants/{userId}` | **unwired** |
+| GET | `/api/v1/admin/tickets` | wired · paginated — the staff queue |
+| GET | `/api/v1/admin/tickets/{id}` | wired — the staff thread, **internal notes visible** |
+
+All `[Authorize]`; the two `admin/tickets` routes are `DynamicPermission`. Note the hardcoded nested
+`admin/` route segment — see [index.md](index.md#route-shape-exceptions).
+
+### Phantom — 1
+
+| Client call | REQ | Live? |
+| --- | --- | --- |
+| `POST /api/v1/tickets/{id}/assign` | REQ-063 | **Yes — this domain's mock is off.** Gated behind `TICKET_LIFECYCLE_ENABLED`, default **off**, so nothing calls it today |
+
+> **`tickets/constants.ts` is stale on this.** It says the gate is off because "the backend has no
+> close/reopen/assign routes yet (REQ-063)". `close` and `reopen` **exist and are wired.** Only `assign`
+> is missing. The gate could be turned on for close/reopen alone.
+
+## `isInternal` is a query-layer boundary
+
+The hardest rule in this domain, and the easiest to get wrong in a UI:
+
+- `TicketMessageDto` carries `isInternal`, so the field **is** on the wire shape.
+- **The filtering is not.** A non-staff caller's thread query never returns an internal row, and a
+ non-staff caller can never *set* one. Both are enforced at the query layer, in the handler — **never in
+ the UI**.
+- Consequence for the client: it must not model internal notes as "rows to hide". They do not arrive. A
+ client-side filter would be a second, weaker gate that hides a leak rather than preventing one.
+- Consequence for the server: any new ticket read must repeat the filter. There is no global interceptor
+ doing it.
+
+## Shape rules the JSON does not express
+
+- **Unread is computed server-side against `last_read_at`**, which is stamped **when the participant
+ fetches the user-facing thread** (`GET /tickets/{id}`) — not by a separate mark-read call. So opening a
+ thread is what clears its badge. `unreadCount` counts non-internal messages from *others* after that
+ stamp, and is **0 on the admin queue** by definition.
+- **`clientMessageId` is optimistic-send idempotency** (REQ-028): a retried send with the same key returns
+ the original message and echoes the key back on `PostMessageResult`. This is what makes the client's
+ retry-in-place send safe. It is **not** the `Idempotency-Key` header — it is a body field, and it is the
+ only place in the API that works this way.
+- **The message author is a role label, not a name** — confirmed intentional, for privacy.
+ `TicketMessageDto` carries `senderId` only; the client derives the author label from the participant
+ role. No raw identity is exposed, and none should be added.
+- **`referenceCode` is the human-facing id** shown to users and quoted in support. Treat it as opaque.
+- `bookingId` and `refundId` on the summary link a ticket to what it is about; the `bookingId` query
+ filter is how the client jumps from a booking to its coordination thread.
+- Ticket bodies are **encrypted at rest** (refinement-phase-9).
+
+## Enums
+
+| Vocabulary | Values | Note |
+| --- | --- | --- |
+| `TicketStatus` | `open` `closed` | matches `Entities/Messaging/TicketCodes.cs` |
+| `TicketCategory` | `coordination` `support` `refund` `emergency` | matches |
+| `TicketAuthorRole` | `customer` `nurse` `admin` **`system`** | **the one cross-side mismatch in the API** |
+| `MessageSendStatus` *(client-only)* | `sent` `sending` `failed` | optimistic-send UI state, never on the wire |
+
+> **`system` is client-only.** `TicketCodes` defines `customer`, `nurse`, `admin`. The client's
+> `TicketAuthorRole` adds `system` for platform-generated messages. Widening a union on the *reading* side
+> is safe — the client can render a value the server never sends — but it means a reader of the client
+> types would wrongly conclude the server emits `system`. Either the server should define it or the client
+> should drop it; today it is a documented asymmetry.
+
+## Open REQs
+
+| REQ | Status | Effect |
+| --- | --- | --- |
+| REQ-028 | delivered | `unreadCount` + `lastMessageAt` on the summary, `bookingId` filter, `clientMessageId` dedupe, role-label authors — all present |
+| REQ-059 | open | No last-message preview and no author role on the summary, and no unread-*total* read. The real inbox shows subject + status + time with no preview |
+| REQ-060 | deferred | No message photo attachments. The affordance is designed and gated off |
+| REQ-063 | open, **narrower than filed** | `close` and `reopen` are delivered and wired. Only `assign` is missing → 1 phantom |
diff --git a/docs/integration/domains/verification.md b/docs/integration/domains/verification.md
new file mode 100644
index 0000000..bacb448
--- /dev/null
+++ b/docs/integration/domains/verification.md
@@ -0,0 +1,117 @@
+# verification — the nurse trust pipeline
+
+> Client seam `client/src/services/verification/` · `USE_VERIFICATION_MOCK = true` (**mock is primary**) · 17 server ops
+> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
+
+The largest domain, and the one the whole marketplace's trust claim rests on. A nurse is not bookable until
+this pipeline says so.
+
+## Endpoints
+
+### Nurse-facing
+
+| Method | Path | Verdict |
+| --- | --- | --- |
+| GET | `/api/v1/nurse_verification` | wired — **the one cached status query** |
+| POST | `/api/v1/nurse_verification/submit` | wired |
+| POST | `/api/v1/nurse_verification/steps/{stepId}/upload_url` | wired — presigned PUT |
+| POST | `/api/v1/nurse_verification/steps/{stepId}/documents` | wired — confirm the upload |
+| POST | `/api/v1/nurse_verification/steps/identity_kyc/run` | wired |
+| POST | `/api/v1/nurse_verification/steps/shahkar_match/run` | wired |
+| POST | `/api/v1/nurse_verification/steps/bank_account_verification/run` | wired |
+| POST | `/api/v1/nurse_verification/credential_details` | **unwired** — the write half of REQ-011; the client has no read-back (REQ-056) |
+
+### Public
+
+| Method | Path | Auth | Verdict |
+| --- | --- | --- | --- |
+| GET | `/api/v1/nurses/{nurseId}/trust_badge` | **anonymous** | wired |
+
+### Admin — all `DynamicPermission` + `sensitive`
+
+| Method | Path | Verdict |
+| --- | --- | --- |
+| GET | `/api/v1/admin_verifications` | wired · paginated |
+| GET | `/api/v1/admin_verifications/{nurseVerificationId}` | wired |
+| POST | `/api/v1/admin_verifications/steps/{stepId}/decide` | wired — **per-step** decision |
+| POST | `/api/v1/admin_verifications/{nurseVerificationId}/suspend` | **unwired** |
+| POST | `/api/v1/admin_verifications/scan_expiring` | **unwired** — an ops one-shot; the scheduler runs it |
+| GET | `/api/v1/admin_verification_step_types` | **unwired** — the step catalogue is data-driven; no editor |
+| POST | `/api/v1/admin_verification_step_types` | **unwired** |
+| DELETE | `/api/v1/admin_verification_step_types/{id}` | **unwired** |
+
+### Phantom — 3
+
+| Client call | REQ | Note |
+| --- | --- | --- |
+| `GET /api/v1/admin_verifications/documents/{id}/url` | REQ-034 | Deferred. No on-demand signed document URL, so the queue cannot open an uploaded document |
+| `POST /api/v1/admin_verifications/{id}/approve` | REQ-034 | Deferred. Today approval is **per-step** only — the aggregate flips when the last required step passes |
+| `POST /api/v1/admin_verifications/{id}/reject` | REQ-034 | Deferred |
+
+## The two rules that must not be broken
+
+1. **`status` is the source of truth; `nurse_profiles.is_verified` is derived.** The boolean is written
+ **only** by the finalize transaction when the aggregate reaches `approved`, as one guarded
+ cross-aggregate flip: load both tracked, mutate through one pure domain helper, commit once. Never set
+ `is_verified` from a profile write, a controller, or out of band. See [profiles.md](profiles.md).
+2. **The step catalogue is data, not code.** `verification_step_types` rows define which steps exist,
+ which are required, and which are automated. Adding a step is a data change. The client must not
+ hardcode the step list — it renders whatever `VerificationStatusDto.steps` contains.
+
+## Shape rules the JSON does not express
+
+- **`VerificationStatusDto` is `{ status, isBookable, blockingSteps, steps }`** — the server computes
+ `isBookable` and names the `blockingSteps`. The client does not derive bookability from the step array.
+ This is why the client keeps **one cached status query** and every screen reads it.
+- **`expiresAt` on a step is real.** MoH competency licences and INO membership lapse; `scan_expiring`
+ reverts a lapsed step to `expired`, which re-gates bookability. `expired` is therefore a normal state,
+ not an error.
+- **Document upload is a two-step presign flow**: `upload_url` returns a presigned PUT, the client uploads
+ **directly to storage**, then `documents` confirms. The document bytes never transit the API.
+ `Seams:ObjectStorage:PresignExpirySeconds` (900) bounds the window.
+- **The three `run` steps are seams, each independently switchable** —
+ `Seams:IdentityKyc:Provider`, `Seams:Shahkar:Provider`, `Seams:BankOwnership:Provider`, all `mock` by
+ default, all `finnotech` for the real bridge (sharing `Seams:Finnotech` credentials). Designated test
+ values make each failure path reachable: `SharedSimPhone` `09120000000`,
+ `MismatchNationalId` `1111111111`, `FailNationalId` `0000000000`,
+ `MismatchIban` `IR0000…0000`.
+- **`shahkar_match` failing as *shared SIM* is a distinct outcome** from a plain phone↔national-id
+ mismatch, and the UI must say which — a shared family SIM is a common, innocent case.
+- **National id and licence numbers are encrypted at rest**; the admin queue sees them only where the
+ decision requires it.
+- The nurse's INO membership field is **locked once submitted** (it feeds the public trust badge).
+
+## Two vocabularies for one thing
+
+`GET /me` reports a *nurse verification summary* using `not_started` `in_progress` `pending_review`
+`verified` `rejected`. This domain's aggregate uses `not_started` `pending` `in_review` `approved`
+`rejected` `suspended`. **They are two read models over the same source of truth, not a drift** — but they
+are not interchangeable strings. Map deliberately. See [auth.md](auth.md).
+
+## Enums
+
+| Vocabulary | Values |
+| --- | --- |
+| `VerificationStatus` (aggregate) | `not_started` `pending` `in_review` `approved` `rejected` `suspended` |
+| `VerificationStepStatus` | `not_started` `pending` `in_review` `passed` `failed` `expired` |
+| `StepTypeCode` | `identity_kyc` `shahkar_match` `moh_competency_license` `ino_membership` `criminal_record` `bank_account_verification` |
+| `CredentialType` | `moh_competency_license` `ino_membership` `criminal_record` |
+| `VerificationMethod` | `manual` `portal` `api` |
+| `BadgeState` *(client, from `TrustBadgeDto`)* | `verified` `unverified` `expired` |
+
+The first two are C# enums serialised as snake_case codes (`Entities/Verification/VerificationStatus.cs`,
+`VerificationStepStatus.cs`); the rest are string constants in `VerificationStepTypeCodes.cs`. All verified
+identical to the client's unions.
+
+`TrustBadgeDto` is `{ nurseId, isVerified, approvedAt, credentialTypes }` — a summary, with no per-step
+detail. That absence is REQ-043.
+
+## Open REQs
+
+| REQ | Status | Effect |
+| --- | --- | --- |
+| REQ-034 | deferred | No nurse-grouped queue, no on-demand document URL, no whole-verification approve/reject → 3 phantom routes. This is the main reason the seam is mocked |
+| REQ-043 | open | `TrustBadgeDto` has no per-step detail (step codes + decision dates), so the public verification panel shows a summary only |
+| REQ-055 | open | No `submittedAt` on `VerificationStatusDto` — the real B6 screen omits the timestamp line |
+| REQ-056 | open | No nurse-facing read-back of submitted credential details. `credential_details` writes; nothing reads. The real form degrades to blank |
+| REQ-062 | open | No name/phone search and no per-status counts on the admin queue |
diff --git a/docs/integration/index.md b/docs/integration/index.md
index 998fbb1..5ddac87 100644
--- a/docs/integration/index.md
+++ b/docs/integration/index.md
@@ -1,34 +1,152 @@
# Integration — the client↔server seam
-> **Populated by phase 2 — not yet written**, except the OpenAPI snapshot, which phase 0 refreshed.
-> Until then the contract lives in [dev/contracts/](../../dev/contracts/README.md) (frozen 2026-07-13)
-> and the deployment shape in [DEPLOY.md](../../DEPLOY.md).
+The two projects have **no shared build**. Everything that crosses between them is described here, as one
+thing. This page is the whole seam; the four files below are the detail.
-The two projects have no shared build. Everything that crosses between them — envelope shape, casing,
-pagination, idempotency, cookies and refresh, CORS, `NEXT_PUBLIC_API_URL`, three containers behind Caddy,
-a remote SQL Server and the OTP relay — is described **here, as one thing**. Today it is described
-nowhere as one thing, which is the gap this section closes.
-
-## Planned contents
+> Last verified: 2026-07-30 against commit `d3ec723`, `swagger.v1.json` (2026-07-29, 178 paths / 186
+> operations) and the code in `client/src/lib/api/`, `client/src/services/*/`, `server/src/API/`.
| File | Covers |
| --- | --- |
-| `api-contract.md` | Envelope, casing, pagination, error shape, idempotency, auth headers/cookies |
-| `domains/` | One file per service domain, refreshed against the live swagger |
-| [`openapi/`](openapi/README.md) | The machine contract — **snapshot is current as of 2026-07-29** |
-| `config-matrix.md` | Every env var and appsettings key: client, server, docker, the bot |
-| `topology.md` | The runtime dependency graph — 3 containers + Caddy + remote SQL + OTP relay |
+| [api-contract.md](api-contract.md) | Envelope, casing, pagination, errors, idempotency, auth, money, rate limits |
+| [domains/](domains/index.md) | 22 files, one per client `services/` domain — every endpoint, verdicted against the live swagger |
+| [openapi/](openapi/README.md) | The machine contract + how to regenerate it |
+| [config-matrix.md](config-matrix.md) | Every env var and appsettings key: client, server, docker, the bot |
+| [topology.md](topology.md) | The runtime dependency graph — 3 containers, Caddy, remote SQL, the OTP relay |
-## What phase 0 already established
+---
-The machine contract barely drifted. Between the 2026-07-13 snapshot and the live server on 2026-07-29:
-**one endpoint added** (`POST /api/v1/webhooks/payouts/{provider}`), **one schema changed**
-(`GeneratePayoutBatchCommand`), nothing removed. Full diff in
-[_plan/open-contradictions.md § OpenAPI drift](../_plan/open-contradictions.md#openapi-drift).
+## Transport
-So phase 2's real work is the **17 hand-written** `dev/contracts/domains/*.md` files, which were frozen
-the same day and have never been checked against anything mechanically — not the JSON.
+HTTP/JSON. The client reads one base URL — `NEXT_PUBLIC_API_URL` — and prefixes every path with
+`/api/v1/`. Locally that is `http://localhost:5002` (**plain HTTP**; `launchSettings.json` binds no TLS);
+deployed it is `https://api.balinyaar.ir`, which Caddy terminates and forwards to `balinyaar-api:8080`.
-Also open for this section: **C-2** (`SET_VIA_USER_SECRETS_OR_ENV` names a store that was removed),
-**C-3** (`https://localhost:5002` in 18 places vs `http://` in `launchSettings.json`), **C-8** (two
-contract files for messaging), **C-9** (a `v1.1` OpenAPI document that may not exist).
+**gRPC exists and the client does not use it.** `Baya.Web.Plugins.Grpc` serves exactly one service (User)
+over HTTP/2 on the same port. Nothing in `client/` speaks it. Treat it as an internal affordance.
+
+**Two OpenAPI documents are served, `v1` and `v1.1`.** All 55 controllers declare `[ApiVersion("1")]`, and
+`ApiVersionDocumentProcessor` keeps only paths whose URL contains the document's version segment — so
+**`v1.1` is served but contains zero paths.** `v1` is the contract.
+
+## The envelope
+
+Every response — success *and* failure — is `ApiResult`. The payload is always under `data`.
+
+```json
+{ "isSuccess": true, "statusCode": 200, "message": "Success",
+ "requestId": "0af7651916cd43dd8448eb211c80319c", "code": null, "data": { } }
+```
+
+`requestId` is the **W3C trace id** of the request (`Activity.Current.TraceId`), the same id
+OpenTelemetry traces on — so a support ticket maps 1:1 to a trace. `code` is an optional stable
+machine-readable error code (`otp_locked`, …), omitted from the wire when null.
+
+The client never unwraps centrally: `clientFetch`/`serverFetch` return the **raw envelope**, and each
+service calls `unwrap()` from [`client/src/lib/api/types.ts`](../../client/src/lib/api/types.ts).
+
+## Casing — camelCase bodies, snake_case URLs
+
+Verified mechanically against the live swagger: of **427 distinct property names, 0 contain an underscore
+and 0 are PascalCase.** JSON bodies are camelCase. URL *segments* are snake_case, produced by the server's
+`SnakeCaseParameterTransformer` from `[controller]`/`[action]` tokens (`SetPrimary` → `/set_primary`).
+
+Three routes break the snake_case rule with hardcoded hyphens — `admin/partner-centers`, `admin/tickets`,
+`admin/reviews` — see [domains/index.md](domains/index.md#route-shape-exceptions).
+
+## Pagination
+
+`{ items, total, page, pageSize }` (`total`/`page`/`pageSize` are int32). 29 operations are paginated.
+The **declared** query-param names are not uniform — 25 declare `Page`/`PageSize`, 3 declare
+`page`/`pageSize`, and `GET /search/nurses` declares `page`/`page_size`. Model binding is
+case-insensitive so the first two are interchangeable; `page_size` is a *different name* and is not.
+Full table in [api-contract.md](api-contract.md#pagination).
+
+## Errors
+
+`200` · `400` validation (field errors under `data`) · `401` unauthenticated · `403` forbidden ·
+`404` not found (**also returned for a tenancy mismatch**, deliberately, so a 403 never confirms a row
+exists) · `409` state-machine/idempotency conflict · `422` · `424` · `429` rate-limited · `5xx`.
+
+`clientFetch` behaviour: **401** → one silent refresh + retry, then clear cookies, toast, redirect to
+login (no throw); **403** and **5xx** → toast + throw `ApiError`; **other 4xx** → throw without toasting
+(the calling hook owns the message); **network failure** → toast + `ApiError(0)`.
+
+## Auth
+
+**Bearer header, not cookie auth.** The JWE access token is *stored* in a cookie the client reads itself
+and *sent* as `Authorization: Bearer `. The server's CORS policy therefore does **not** allow
+credentials, and the client sends no `credentials: 'include'`.
+
+The token is opaque to the client (signed + AES-encrypted). Role and identity come from `GET /api/v1/me`;
+a multi-role user picks one with `POST /api/v1/me/select_role`. On a 401 the client runs one
+single-flight silent refresh (`POST /api/v1/auth/refresh`) — the server rotates the pair and detects
+reuse, so a replayed refresh token kills the session. `/auth/refresh`, `/auth/request_otp` and
+`/auth/verify_otp` are excluded from the retry.
+
+**20 of 186 operations are anonymous** — the OTP pair, catalog + geo reference reads, the public nurse
+search/profile/reviews/trust-badge reads, `ping`, the three webhooks, and Development's
+`GET /api/v1/dev/last_otp/{phone}`. Full list in [api-contract.md](api-contract.md#the-anonymous-surface).
+
+## Idempotency
+
+`Idempotency-Key` is a request header. The server reads it on exactly **two** endpoints:
+
+| Endpoint | Key scope |
+| --- | --- |
+| `POST /api/v1/bookings/{bookingRequestId}/payments` | one key per payment *attempt*, reused across retries of that attempt |
+| `POST /api/v1/checkout_bnpl/initiate` | same, per BNPL attempt |
+
+The client also sends it on `admin_payouts` process/retry, where **the server does not read it** — see
+[domains/payouts.md](domains/payouts.md). Webhooks do not use the header; they dedupe on the provider's
+`external_event_id`. The header is allowed through CORS but is **not declared in swagger** (it is read
+from `Request.Headers`, not bound as a parameter).
+
+## Money
+
+**IRR Rials, integer, no floats, anywhere.** On the wire the direction matters, and it is consistent:
+
+- **Outbound (DTOs/results): a digit string.** All 68 money properties on read models are `type: string`.
+ Parse with the `@/utils` BigInt helpers — never `Number()`.
+- **Inbound (commands): `integer/int64`.** All 3 money properties on command bodies.
+
+Toman is display-only and is converted **only** inside a provider adapter at its boundary
+(`ICurrencyNormalizer`, `Seams:Currency:TomanToIrrMultiplier`). `gross = commission + payout` always,
+and VAT is on Balinyaar's commission only. See [docs/rules/server/money.md](../rules/server/money.md)
+and [docs/rules/client/services.md](../rules/client/services.md).
+
+## Enums
+
+**Swagger declares no string enums** — 1 of 339 schemas has an `enum`, and it is the integer
+`ApiResultStatusCode`. Every status/code field is a bare `string` on the wire. The vocabulary therefore
+lives in the domain files here, cross-checked against the server's `Baya.Domain` code sets and the
+client's string-literal unions. **All 18 shared vocabularies match on both sides** as of this stamp; the
+one exception is noted in [domains/tickets.md](domains/tickets.md).
+
+---
+
+## What the server owes the client
+
+1. The `ApiResult` envelope on every response, with `requestId` populated and `code` set on any failure
+ the client must branch on.
+2. camelCase bodies; snake_case URL segments; `{ items, total, page, pageSize }` on every list.
+3. `404`, never `403`, for a row the caller does not own.
+4. Money as a digit string outbound; the three-amount split guaranteed server-side.
+5. A masked address and only unencrypted `customerNotes` before a booking is confirmed; full care
+ instructions only after, only to the assigned nurse and admin.
+6. `is_internal` filtered at the query layer — a non-staff caller can never read or set one.
+7. Idempotent behaviour on the two keyed endpoints, and `409` (not `500`) on a converged replay.
+8. Reachability: `/healthz/live`, `/healthz/ready`, and CORS origins that list the client's real origin.
+
+## What the client owes the server
+
+1. `Authorization: Bearer ` on every authenticated call — and nothing else for auth. No cookies
+ cross the wire.
+2. `Accept-Language` (`fa` default) on every call, from the active locale segment.
+3. `Content-Type: application/json` — **except** `FormData` bodies, where the browser sets the multipart
+ boundary itself.
+4. One stable `Idempotency-Key` per payment/BNPL attempt; a new attempt gets a new key.
+5. `page`/`pageSize` within the server's cap; never an unbounded list request.
+6. IRR integers inbound; Toman conversion only at the UI boundary.
+7. Exactly one silent refresh per 401, single-flighted, and never on the refresh/OTP endpoints.
+8. No derived money. The client never computes commission, VAT, refund amounts or payout dates.
diff --git a/docs/integration/openapi/README.md b/docs/integration/openapi/README.md
index 3f99b69..297a129 100644
--- a/docs/integration/openapi/README.md
+++ b/docs/integration/openapi/README.md
@@ -42,15 +42,38 @@ Two things that cost time the first run:
Then update the table above — date, commit, and endpoint count — in the same change. A snapshot whose
provenance is unrecorded is what this chain exists to stop.
-## Scope
+## Scope — `v1`, and why `v1.1` is empty
-`v1` only. [The old README](../../../dev/contracts/openapi/README.md) claimed the server publishes
-documents `v1` **and** `v1.1`, but only `v1` has ever been committed and `v1.1` was not fetched during
-the phase 0 survey. `UNVERIFIED:` check `/swagger/v1.1/swagger.json` when the server is next up
-(contradiction C-9).
+Both documents **are** registered: `Program.cs` calls `AddSwagger("v1", "v1.1")`, so
+`/swagger/v1.1/swagger.json` is served. It contains **zero paths**.
+
+`ApiVersionDocumentProcessor` removes every path whose URL does not contain the document's own version
+segment, and all **55 controllers declare `[ApiVersion("1")]`** with the route template
+`api/v{version:apiVersion}/…`. So every URL contains `v1` and none contains `v1.1`.
+
+That resolves contradiction **C-9**: the old README's "publishes `v1` and `v1.1`" was literally true and
+substantively empty. `v1` is the contract. Only `v1` is worth committing.
+
+## One provenance wrinkle
+
+The document's `servers` block reads `http://127.0.0.1:5099`, while the regeneration command above uses
+port 5002. NSwag records whichever host answered the request, so this only means the phase-0 snapshot was
+taken from a run bound to 5099. **The document content is port-independent** — no path, schema or parameter
+depends on it — so this is a provenance note, not a defect. Re-taking the snapshot from 5002 would change
+that one string and nothing else.
## The human contract
-The prose half — one file per domain — lands in [`../domains/`](../index.md) in phase 2. **The two must
+The prose half — one file per domain — is in [`../domains/`](../domains/index.md): **22 files, one per
+client `services/` domain**, with all 186 operations assigned to exactly one of them. **The two must
agree**: this JSON is the wire truth, the markdown explains it. When they disagree, the JSON wins and
the markdown is wrong.
+
+Two things the JSON **cannot** tell you, which is why the markdown exists:
+
+- **Enum vocabularies.** Exactly 1 of 339 schemas has an `enum`, and it is the integer
+ `ApiResultStatusCode`. Every status/code field is a bare `string`. The vocabularies live in the domain
+ files, cross-checked against `Baya.Domain`'s code sets.
+- **`Idempotency-Key`.** Two endpoints require it, and neither declares it — it is read from
+ `Request.Headers`, not bound as a parameter. See
+ [`../api-contract.md`](../api-contract.md#idempotency).
diff --git a/docs/integration/topology.md b/docs/integration/topology.md
new file mode 100644
index 0000000..19d444d
--- /dev/null
+++ b/docs/integration/topology.md
@@ -0,0 +1,145 @@
+# Topology — the runtime dependency graph
+
+What talks to what at runtime, what breaks when each hop is down, and which file configures it. This is the
+answer to a deploy question; the deploy *procedure* is [DEPLOY.md](../../DEPLOY.md) and every key is in
+[config-matrix.md](config-matrix.md).
+
+> Last verified: 2026-07-30 against commit `d3ec723`, `docker-compose.yml`, `deploy/Caddyfile` and
+> `server/src/API/Baya.Web.Api/Program.cs`.
+
+---
+
+## Deployed
+
+```mermaid
+graph LR
+ browser["Browser
balinyaar.ir"]
+
+ subgraph net["caddy_net (external docker network)"]
+ caddy["Caddy
pre-existing, not in this repo
TLS terminator"]
+ web["balinyaar-web:3000
Next.js 16"]
+ api["balinyaar-api:8080
ASP.NET Core 10"]
+ relay["balinyaar-otp-relay:5010
Node, zero deps"]
+ proxy["hysteria-client:8081
pre-existing"]
+ end
+
+ sql[("Remote SQL Server
87.107.152.16:1433
not containerised")]
+ vol[["api-object-storage
docker volume"]]
+ tg["api.telegram.org"]
+ rails["External rails
PSP · BNPL · Finnotech · Neshan · مودیان
all mocked by default"]
+
+ browser -->|"HTTPS"| caddy
+ caddy -->|"HTTP :3000"| web
+ browser -->|"HTTPS api.balinyaar.ir
every API call"| caddy
+ caddy -->|"HTTP :8080
+ X-Forwarded-For"| api
+ api -->|"TCP 1433"| sql
+ api -->|"HTTP + X-Api-Key"| relay
+ api --> vol
+ api -.->|"per seam config"| rails
+ relay -->|"HTTP CONNECT"| proxy
+ proxy --> tg
+
+ web -.->|"builds absolute URLs only
no server-to-server calls"| api
+```
+
+**The single most important edge is the dotted one.** The browser calls the API directly at
+`https://api.balinyaar.ir`; the `web` container does **not** proxy or fetch on the browser's behalf. That is
+why `NEXT_PUBLIC_API_URL` must be the **public hostname**, never the container name — a container name is
+unresolvable from a browser. It is also why every `NEXT_PUBLIC_*` change needs an image rebuild: the value
+is compiled into the bundle.
+
+## Every edge
+
+| # | From → To | Carries | Breaks if down | Configured in |
+| --- | --- | --- | --- | --- |
+| 1 | browser → Caddy | All HTTPS for both hostnames | Everything. Caddy is the only TLS terminator and the only published port | `deploy/Caddyfile` (pasted into the pre-existing Caddy) |
+| 2 | Caddy → `balinyaar-web:3000` | The Next.js app shell, RSC payloads, static assets | The site does not load. **The API keeps working** — they are independent hostnames | `Caddyfile` · `docker-compose.yml` |
+| 3 | browser → Caddy → `balinyaar-api:8080` | Every `/api/v1/*` call, bearer-authenticated | The site loads and every screen shows its error state. No data, no login | `NEXT_PUBLIC_API_URL` (build-time) · `Cors:AllowedOrigins` |
+| 4 | Caddy → API, `X-Forwarded-For` | The real client IP | The rate limiter partitions every request onto **Caddy's** IP, so one noisy client 429s everyone | `ForwardedHeaders:KnownNetworks` — the docker bridge ranges |
+| 5 | API → remote SQL Server | All application data + the Serilog sink | **Total outage.** `/healthz/ready` fails and the API will not start | `ConnectionStrings:SqlServer` / `:logDb` |
+| 6 | API → `balinyaar-otp-relay:5010` | OTP codes, `X-Api-Key` authenticated | **Nobody can log in.** Every other authenticated screen keeps working for existing sessions | `Seams__Sms__Telegram__BaseUrl` (compose) · `Seams:Sms:Telegram:ApiKey` |
+| 7 | relay → `hysteria-client:8081` → Telegram | The code delivery itself | Same as 6 — codes are generated but never arrive. Fails **at relay boot** with a clear message, not silently per-OTP | `TELEGRAM_PROXY_URL` |
+| 8 | API → `api-object-storage` volume | Verification documents, avatars | Uploads fail; `/healthz/ready` fails (it does a real write probe). **Without the volume, existing documents vanish on the next `up --build`** | `Seams__ObjectStorage__RootPath` + the named volume |
+| 9 | API → external rails | Payments, BNPL, KYC, geocoding, e-invoicing | **Nothing, by default** — every rail is `mock`. Real behaviour begins the moment a `Provider` selector changes | `Seams::Provider` — see [config-matrix.md](config-matrix.md#the-seam-selectors) |
+| 10 | PSP / BNPL / transferor → API webhooks | Payment capture, BNPL settlement, payout reconciliation | Payments are taken but **bookings are never confirmed** — the webhook is what creates the booking | `webhook` rate-limit policy, anonymous routes, per-provider signing secrets |
+
+## Ports
+
+| Port | Where it applies | Note |
+| --- | --- | --- |
+| `443` | The host | Caddy. **The only published port on the machine** |
+| `3000` | `caddy_net` only | Next.js. Not published |
+| `8080` | `caddy_net` only | The API **in-container** |
+| `5002` | A developer laptop only | The local `launchSettings.json` port — **plain HTTP**, and nothing in the deployment uses it |
+| `5010` | `caddy_net` only | The OTP relay |
+| `1433` | Outbound to `87.107.152.16` | Remote SQL Server |
+
+> **5002 vs 8080 catches people out.** Most prose in this repo says 5002 because that is the local port.
+> The container binds 8080 (the ASP.NET default in a container) and the Caddyfile points there.
+
+## Startup order
+
+`depends_on: [otp-relay]` puts the relay before the API, but that only orders *container start*, not
+readiness. What actually gates the API's boot:
+
+1. **`StartupSecretsGuard`** — refuses to start on a missing or placeholder secret, before any service reads
+ config.
+2. **A reachable SQL Server** — required to start, full stop.
+3. **In Development** (which is what the deployment runs): apply migrations, then seed default users,
+ payment gateways, the demo world and the demo lifecycle. All idempotent — a re-boot logs
+ `Demo world already seeded — no-op.` **First boot takes ~40 s** against the remote DB.
+4. **When deployed as a non-Development environment**: DDL is a separate one-shot
+ (`dotnet Baya.Web.Api.dll migrate`); boot only *checks* the schema is current and fails fast on a pending
+ migration, then seeds roles.
+
+The relay refuses to start without `API_KEY` (min 16 chars) or with an unreachable proxy — both fail loudly
+at boot rather than per-request.
+
+## Request pipeline inside the API
+
+Order matters, and two placements are deliberate:
+
+```
+UseForwardedHeaders ← first, so the resolved client IP is in place before anything reads it
+UseSwaggerAndUi
+UseRouting
+UseCors ← after routing, BEFORE the rate limiter and auth, so a pre-flight OPTIONS
+UseRateLimiter is answered rather than rejected as 429 or 401
+UseAuthentication
+UseAuthorization
+MapControllers
+UseMetrics · UseHealthChecks
+ConfigureGrpcPipeline
+```
+
+## What is not in the graph
+
+| | Why |
+| --- | --- |
+| A database container | The DB is remote and pre-provisioned. `RUNBOOK.md`'s local-SQL-in-Docker path describes a **different world** — an unseeded one (contradiction **C-5**) |
+| Redis | `ICacheService` / `IDistributedLock` are single-process today. When a multi-instance deployment needs a real one, a `redis` readiness check gets added alongside it |
+| A job runner | The weekly payout batch is generated by an **in-process** `IRecurringJob` scheduler. No external cron, no queue |
+| A message broker | Nothing is asynchronous across a process boundary |
+| An OTLP collector | `OpenTelemetry:Otlp:Endpoint` is unset, so nothing is exported. Prometheus scrapes `/metrics` directly |
+| A second API instance | Single instance. Multi-instance needs the distributed lock to become real first |
+
+## Local development
+
+Same code, a different graph — no Caddy, no containers, and the **same remote database**:
+
+```
+localhost:3000 (npm run dev) ──▸ localhost:5002 (dotnet run, plain HTTP)
+ │
+ ├──▸ 87.107.152.16:1433 (the same remote DB)
+ └──▸ 127.0.0.1:5010 (the relay, if you run it)
+```
+
+Three things to know before the first run:
+
+- **The API is plain HTTP locally.** `dotnet dev-certs https --trust` is not needed and there is no
+ certificate to trust (contradiction **C-4**).
+- **You share the deployment's database.** Booting in Development migrates and seeds against it. The
+ seeders are idempotent, but you and the demo site are looking at the same rows.
+- **If you run the relay locally**, copy `Seams:Sms:Telegram:ApiKey` from `appsettings.Development.json`
+ into your own `telegram-otp-bot/.env`. The value in `.env.example` is published and
+ `TelegramSmsSender` deliberately rejects it.