# Backend Phase 6 — Nurse verification & credentials (mocked vendors) > **Mission:** build the **trust engine** — the platform's entire brand. A **data-driven**, platform-owned > verification pipeline (steps are *rows*, not an enum) that mixes automated KYC-vendor checks with manual > admin document review, rolls every per-step outcome into one authoritative `nurse_verifications.status`, > maintains a structured, queryable **credential registry** (license numbers, authority, holder name, > issue/expiry), and **transactionally flips `nurse_profiles.is_verified`** the moment every required step > passes (and reverses it on suspension). Documents live in object storage behind signed URLs — only > metadata + an integrity hash touch the DB. Every external vendor (Shahkar, identity KYC/liveness, the > MoH/INO/criminal-record portals, IBAN ownership) is **mocked behind a DI seam** so the real swap is > implementation-only. After this phase a nurse can finally become bookable. > > **Track:** backend · **Depends on:** [b3](./backend-phase-3.md) (`nurse_profiles`, `nurse_bank_accounts`, `IBankAccountOwnershipVerifier`), [b1](./backend-phase-1.md) (`support_alerts`, the typed config accessor), [b0](./backend-phase-0.md) (`IObjectStorage`, `IFieldEncryptor`, `ICurrentUser`, audit interceptor) · **Unlocks:** search visibility ([b7](./backend-phase-7.md)); frontend **f5-b6** > **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional. --- ## 1. Context — where this sits This is **backend phase b6**, the verification leg of the build. Balinyaar is a *trust-first* home-nursing marketplace; the product docs are blunt that "verified trust is your **entire brand**." Vetting is **platform-owned, non-optional, and performed at the authoritative source** — a nurse's service variants become bookable **only after every required verification step passes**. This phase builds that gate: the 6-step pipeline, the admin review queue, the structured credential registry that powers the public trust badge and renewal alerts, and the **one transaction** that flips `nurse_profiles.is_verified`. It sits between catalog ([b5](./backend-phase-5.md), which built the bookable service variants) and search ([b7](./backend-phase-7.md), which surfaces only verified nurses) — and it is the hard prerequisite that makes the `nurse_search_index.is_searchable` projection meaningful. **What already exists (do not rebuild) — built by prior phases:** - **`nurse_profiles`** — [b3](./backend-phase-3.md) built the nurse extension off `users` (1:1 UNIQUE `user_id`), carrying the **guarded `is_verified` BIT DEFAULT 0** (no public setter — this phase owns the only write), `is_accepting_bookings`, the denormalized rating aggregates, and soft-delete. **The legacy `verification_status` column was deliberately CUT** — `nurse_verifications.status` is the sole source of verification truth. Do **not** reintroduce a second copy. - **`nurse_bank_accounts`** — [b3](./backend-phase-3.md) built the payout-IBAN table (`iban` enc, `iban_hash` UNIQUE, `is_primary` filtered-UNIQUE per nurse, `is_verified`, `matched_national_id`, `account_holder_from_bank`, `ownership_vendor_ref`, `verified_by_admin_id`) and the **`IBankAccountOwnershipVerifier`** seam (استعلام شبا / Sheba inquiry). This phase's `bank_account_verification` step **couples to that table and reuses that seam** — it does not re-register or re-verify bank accounts. - **`users` / identity** — [b2](./backend-phase-2.md)/[b3](./backend-phase-3.md): `users.national_id` (enc, **NULL until KYC passes**), `users.gender`, `shahkar_verified_at`/`national_id_verified_at` (reset to NULL on phone change), and admin RBAC roles for the review queue. The identity-KYC step **populates `users.national_id`** on pass; Shahkar and IBAN ownership both compare against that verified national-ID. - **`support_alerts`** — [b1](./backend-phase-1.md) built the alert table + the **raise API** (built early precisely because verification raises expiry/renewal/shared-SIM alerts). This phase **raises** alerts; it does not build the table. - **The typed, cached config accessor + `platform_configs`** — [b1](./backend-phase-1.md). Read review-SLA hints, expiry-scan cadence, and any verification thresholds through it; never hardcode. - **`IObjectStorage`** — [b0](./backend-phase-0.md): presigned/streamed put/get/delete keyed by an opaque storage key, returning a retrievable URL. Verification documents store bytes here. **Reuse it.** - **`IFieldEncryptor`** — [b0](./backend-phase-0.md): `Encrypt`/`Decrypt`/`Hash`. `credential_number` is encrypted PII through this seam. **Reuse it.** - The b0 foundation: REST surface, `BaseController`, `OperationResult`, CQRS via **`martinothamar/Mediator`** (not MediatR), Mapster, FluentValidation + `ValidateCommandBehavior`, `ICurrentUser` + the audit-field SaveChanges interceptor, rate limiting, `IDateTimeProvider`, `INotificationDispatcher` (in-app write landed via b1). **What this phase introduces:** the five verification tables + seed, the nurse/admin capabilities, the guarded `is_verified` flip transaction, the credential-expiry scanner, the public trust badge — and **three new seams** (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`). The **scheduled cron** that drives the expiry scan is DEFERRED — the scan is admin/manually triggered now (see §3.3). ## 2. Required reading (do this first) - [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and [`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) — especially the *Persistence* (encrypted PII through the field encryptor; one config per entity; projected/paginated reads) and *Architecture* (seams in Application, mocks in Infrastructure) blocks. - [`product/business/02-nurse-verification.md`](../../../product/business/02-nurse-verification.md) — **the business rules**: the six steps and what each proves, the data-driven pipeline, the structured credential registry, continuous monitoring (phone-change re-run, expiry re-verify), and the MVP-vs-DEFERRED line (automated MoH/INO lookup, ML fraud scoring, and the liability-insurance step are all DEFERRED). - [`product/data-model/04-verification-and-credentials.md`](../../../product/data-model/04-verification-and-credentials.md) — **the canonical schema** for the five tables. Mirror these field names exactly (especially the `nurse_credentials` column list and the `UNIQUE` constraints). - [`product/research/verification.md`](../../../product/research/verification.md) — *why* the design is what it is: MoH پروانه صلاحیت حرفه‌ای is the single most important credential and **already bundles the criminal-record screen**; identity is the *easy* layer (buy a KYC vendor, don't build); license check is **manual** today because no public B2B API exists; the criminal record is **consent-gated to the person** (nurse-uploaded + re-requested). This is the source of the "manual today, API later" seam shape. - The **identity digest** for the cross-domain invariants: `dm_identity_geo_services_verif.md` (`is_verified` is write-guarded; `national_id` NULL until KYC; `shahkar_verified_at` resets on phone change; `nurse_search_index.is_searchable` depends on `is_verified`). - **Code to mirror:** [b3](./backend-phase-3.md)'s `nurse_bank_accounts` config + the `IBankAccountOwnershipVerifier` usage; [b1](./backend-phase-1.md)'s `support_alerts` raise API + the typed config accessor + the seed-migration pattern (for `verification_step_types`); [b0](./backend-phase-0.md)'s `IObjectStorage`/`IFieldEncryptor` usage and a `ServiceConfiguration/` seam registration; any existing `Features//{Commands|Queries}/**` for handler structure and `OperationResult` returns. - **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) (envelope, routing) — money is not central here but follow the format. - **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-3.md`, `…-1.md`, `…-0.md`, and `reports/mocks-registry.md` (the `IObjectStorage` / `IBankAccountOwnershipVerifier` / `IFieldEncryptor` rows you reuse, and where to add the three new ones). ## 3. Scope — build this Features live under `Baya.Application/Features/Verification/{Commands|Queries}//`; entities in `Baya.Domain/Entities/Verification/`; one `IEntityTypeConfiguration` per entity in `Persistence/Configuration/VerificationConfig/`; one EF migration for the five tables + one **seed migration** for `verification_step_types`. ### 3.1 Entities + migration **`nurse_verifications`** [CORE] — the master per-nurse record; **the SOLE source of verification truth**. - Fields: `id`, `nurse_id` (FK `nurse_profiles`, **UNIQUE** → 1:1), `status` (enum, see below), `submitted_at`, `approved_at`, `rejected_at`, `suspended_at` (all nullable), `rejection_reason` (nullable), `reviewed_by_admin_id` (FK `users` nullable), `internal_notes` (nullable), audit fields, soft-delete. - Relations: 1:1 → `nurse_profiles`; 1:N → `verification_steps`. **`verification_step_types`** [CORE] — the admin catalog of pipeline steps (data-driven, not an enum). - Fields: `id`, `code` (NVARCHAR, **UNIQUE**, stable machine code), `display_name`, `description`, `is_required` (BIT), `is_automated` (BIT), `automation_provider` (NVARCHAR nullable — e.g. `shahkar`, `identity_kyc_vendor`), `sort_order` (int), `is_active` (BIT), audit fields. - **SEED** (via the seed migration) exactly these six stable codes: `identity_kyc` (automated), `shahkar_match` (automated), `moh_competency_license` (manual today), `ino_membership` (manual), `criminal_record` (manual, time-limited), `bank_account_verification` (automated, couples to b3). A new regulatory step (e.g. professional-liability insurance) is one INSERT — **never** hardcode the six in a C# enum. - Relations: 1:N → `verification_steps`. **`verification_steps`** [CORE] — one row per step per nurse. - Fields: `id`, `nurse_verification_id` (FK), `step_type_id` (FK), `status` (enum, see below), `external_response_json` (NVARCHAR(MAX) nullable — raw KYC-vendor response for audit), `expires_at` (DATETIME2 nullable — for time-limited steps), **`is_automated` (BIT — *snapshotted* from the step type at seed time, read this, never the live step-type)**, `started_at`/`completed_at` (nullable), `failure_reason` (nullable), audit fields. **`UNIQUE(nurse_verification_id, step_type_id)`**. - **On expiry → revert to `pending` + `RaiseSupportAlert`** (handled by the scanner, §3.2). - Relations: N:1 → `nurse_verifications`, `verification_step_types`; 1:N → `verification_documents`. **`verification_documents`** [CORE] — **metadata only; bytes never in the DB**. - Fields: `id`, `step_id` (FK `verification_steps`), `object_storage_key` (NVARCHAR — the opaque `IObjectStorage` key), `integrity_hash` (NVARCHAR(64) — content hash to detect tampering/swap), `content_type`, `file_size_bytes` (BIGINT), `original_file_name` (nullable), `uploaded_by_user_id` (FK `users`), audit fields, soft-delete. - **No PII bytes, ever** — the file lives in object storage behind a **signed URL**; the DB row is metadata. - Relations: N:1 → `verification_steps`. **`nurse_credentials`** [MVP] — the structured, queryable credential registry (powers badge + renewal). - Fields (mirror [`04-verification-and-credentials.md`](../../../product/data-model/04-verification-and-credentials.md) exactly): `id`, `nurse_id` (FK `nurse_profiles`), `credential_type` (NVARCHAR — `moh_competency_license` / `ino_membership` / `criminal_record`), `credential_number` (**NVARCHAR(100) encrypted** via `IFieldEncryptor`), `holder_name_snapshot` (NVARCHAR(200) — name as printed, for ID cross-check), `issuing_authority` (NVARCHAR(200)), `issued_at` (DATE nullable), `expires_at` (DATE nullable — drives renewal alerts), `verification_source` (NVARCHAR(300) nullable — portal URL/method), `verification_method` (NVARCHAR(20) — `manual` / `portal` / `api`), `verified_by_admin_id` (FK `users` nullable), audit fields, soft-delete. - Relations: N:1 → `nurse_profiles`; cross-referenced by the relevant `verification_steps`. **Status enums** (define as proper enums; persist per project convention): - `VerificationStatus`: `not_started` | `pending` | `in_review` | `approved` | `rejected` | `suspended`. - `VerificationStepStatus`: `not_started` | `pending` | `in_review` | `passed` | `failed` | `expired`. (`pending` = awaiting submission/automation; `in_review` = awaiting manual admin decision.) ### 3.2 Commands & queries (CQRS, `OperationResult`, never throw for expected failures) | Capability | Type | Route | What it does | | --- | --- | --- | --- | | **`AdminUpsertStepTypeCommand`** / **`AdminListStepTypesQuery`** / **`AdminDeactivateStepTypeCommand`** | Cmd/Query | `POST`/`GET`/`DELETE api/v1/admin_verification_step_types[/{id}]` | Admin CRUD over the step-type catalog (`code` immutable once used, `is_required`/`is_automated`/`automation_provider`/`sort_order`/`is_active`). Adding a step = one row. Cached (read-heavy reference data) with invalidation on write. | | **`SubmitNurseVerificationCommand`** | Command | `POST api/v1/nurse_verification/submit` | For the signed-in nurse (tenancy via `ICurrentUser`): upsert a `nurse_verifications` row, set `status='pending'` + `submitted_at`, and **seed one `verification_steps` row per *active required* `verification_step_types`** (status `pending`/`not_started`, **snapshotting `is_automated`** onto each step). Idempotent: re-submitting does not duplicate steps. | | **`GetNurseVerificationStatusQuery`** | Query | `GET api/v1/nurse_verification` | The nurse's aggregate `status` + per-step list (code, display, status, `expires_at`, failure reason, whether automated) + a "what's blocking bookability" summary. Projected (`AsNoTracking` + `.Select`), tenancy-scoped. Feeds f5's checklist. | | **`RequestDocumentUploadUrlCommand`** | Command | `POST api/v1/nurse_verification/steps/{stepId}/upload_url` | Returns a **signed PUT URL** from `IObjectStorage` for a manual-evidence step (MoH license PDF, INO card, عدم سوء پیشینه). Validates the step belongs to the nurse and accepts uploads. Returns the `object_storage_key` the client echoes back on confirm. | | **`ConfirmDocumentUploadCommand`** | Command | `POST api/v1/nurse_verification/steps/{stepId}/documents` | After the client uploads to the signed URL: persist a `verification_documents` **metadata row** (key, `integrity_hash` computed/echoed, content type, size). **Bytes never enter the DB.** Moves the step to `in_review` if it's a manual step. | | **`RunIdentityKycCommand`** | Command | `POST api/v1/nurse_verification/steps/identity_kyc/run` | Calls **`IIdentityKycProvider`** (national-ID validity + name match + liveness). Persists `external_response_json`; on pass **populates `users.national_id`** + `national_id_verified_at` and sets the step `passed`; on fail sets `failed` + `failure_reason`. Re-aggregates (§ `AggregateAndFinalize`). | | **`RunShahkarMatchCommand`** | Command | `POST api/v1/nurse_verification/steps/shahkar_match/run` | Calls **`IShahkarVerifier`** to bind the login SIM ↔ the **verified** `users.national_id`. Persists `external_response_json` + sets `shahkar_verified_at` on pass. **Shared-SIM is an explicit handled failure state** → step `failed` + `RaiseSupportAlert(shared_sim)`. Requires identity KYC passed first (national-ID present). | | **`RunBankAccountVerificationCommand`** | Command | `POST api/v1/nurse_verification/steps/bank_account_verification/run` | Reuses **`IBankAccountOwnershipVerifier`** (b3) on the nurse's primary `nurse_bank_accounts`: holder national-ID must equal the verified nurse national-ID. On match sets the bank account's `matched_national_id=1` + the step `passed`; on mismatch `failed` (money-mule guard). | | **`AdminListPendingStepsQuery`** | Query | `GET api/v1/admin_verifications?status=in_review&page=&page_size=` | The manual-review worklist (MoH/INO/criminal steps in `in_review`): nurse, step code, submitted docs (with **signed GET URLs** via `IObjectStorage`). Projected + paginated. | | **`AdminGetVerificationDetailQuery`** | Query | `GET api/v1/admin_verifications/{nurseVerificationId}` | Full per-nurse detail for the doc-viewer: all steps, documents (signed URLs), existing credentials, identity name (for cross-check). Projected. | | **`AdminReviewStepCommand`** | Command | `POST api/v1/admin_verifications/steps/{stepId}/decide` | Admin **passes or rejects** a manual step with a required reason on reject. **On pass of a credential-bearing step (MoH/INO/criminal): record a `nurse_credentials` row** (encrypted `credential_number`, `holder_name_snapshot` **cross-checked against the identity name**, authority, issued/expires, `verification_method='manual'`, `verified_by_admin_id`). Writes an audit-log entry (b1 audit interceptor + an explicit decision record). Re-aggregates. | | **`AggregateAndFinalize`** | Command (internal step, called after every outcome write) | — | Re-reads all *required* steps. Sets `nurse_verifications.status` (`pending`→`in_review` when any step is `in_review`; `rejected` if any required step `failed`; **`approved` only when every required step is `passed`**). **On all-passed: flip `nurse_profiles.is_verified = 1` in the SAME transaction**, set `approved_at`. Idempotent — re-running an already-approved verification is a no-op. | | **`AdminSuspendVerificationCommand`** | Command | `POST api/v1/admin_verifications/{nurseVerificationId}/suspend` | Sets `status='suspended'` + `suspended_at` and **reverses the flip — `nurse_profiles.is_verified = 0` in the same transaction** (un-publishing the nurse from search via b7's projection hook). Records reason + admin. | | **`ScanExpiringCredentialsCommand`** | Command | `POST api/v1/admin_verifications/scan_expiring` (admin-triggered; cron DEFERRED) | Scans `nurse_credentials.expires_at` and the matching time-limited `verification_steps.expires_at` (criminal-record especially). For each lapsed credential: **revert the step to `pending`/`expired`, raise a `support_alert` + a renewal-prompt notification**, and if a *required* credential lapsed, re-aggregate (which can drop `is_verified` via suspension semantics). Paginated/batched. | | **`GetVerifiedTrustBadgeQuery`** | Query | `GET api/v1/nurses/{nurseId}/trust_badge` (public) | The public "verified" badge sourced from approved `nurse_verifications` + non-expired `nurse_credentials` (credential **types** held, e.g. "MoH license · INO member", **never the encrypted numbers**). Projected, cached. Feeds the public nurse profile (f6). | - **Controllers:** `NurseVerificationController` (nurse policy, tenancy-scoped), `AdminVerificationController` (admin policy, sensitive — **rate-limited**), and the public trust-badge action (anonymous, read-only). All `sealed : BaseController`, inject `ISender`, return `base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes, `CancellationToken` threaded. - **Validators:** FluentValidation on the submit/run/decide/upload commands (national-ID format on identity run; required rejection reason on a reject decision; required `expires_at` on the criminal-record credential; step ownership/tenancy on the nurse-side commands). ### 3.3 DEFERRED (build the seam/flag, not the feature) - **The scheduled expiry-scan cron** (`CredentialExpiryScannerJob`, emulating Nursys e-Notify cadence) — DEFERRED. The scan logic ships now as `ScanExpiringCredentialsCommand`, admin-triggered; leave a clean entry point + a `verification_expiry_scan_cadence` config key. (Roadmap: a hosted scheduler.) - **Automated MoH/INO license lookup** — DEFERRED behind `ICredentialVerifier` (`verification_method='api'`); the manual review path is the default impl today. Build the seam, not the API client. - **`fraud_flags` / ML fraud scoring** and the **professional-liability-insurance step** — DEFERRED (`fraud_flags` modeled-but-inactive; the insurance step is "addable as a row when required"). Do not build. - **Customer national-ID KYC** — out of scope here (the column stays unused per b3). Do not gate on it. ## 4. Mocks & seams in this phase | Seam | Owner | Mock behaviour | Registry | | --- | --- | --- | --- | | **`IShahkarVerifier`** | **introduced here** | `MatchAsync(phone, nationalId, ct)` returns a deterministic result + a fake `vendor_ref` + an `external_response_json` blob: **pass** when phone+national-ID match a seed map; **force shared-SIM failure** for a known test number (the explicit handled state). No real Shahkar call. | **add a new row** (🟡) | | **`IIdentityKycProvider`** | **introduced here** | `VerifyAsync(nationalId, livenessPayload, ct)` returns deterministic **pass/fail by test national-ID** + a fake vendor ref + `external_response_json`; pass implies a name-match string used for the credential cross-check. No real liveness/OCR. | **add a new row** (🟡) | | **`ICredentialVerifier`** | **introduced here** | The **manual-admin default impl** *is* the mock: `Verify(credentialType, …)` returns `RequiresManualReview` today (`verification_method='manual'`), shaped so an `api`/`portal` impl drops in later for MoH/INO. No portal call. | **add a new row** (🟡) | | `IBankAccountOwnershipVerifier` | reuse from **b3** | Sheba inquiry mock; returns holder national-ID = entered one (or mismatch for a test IBAN). Used by `bank_account_verification`. | reuse row | | `IObjectStorage` | reuse from **b0** | local-disk/in-memory blob store; signed PUT/GET URLs for `verification_documents`. | reuse row | | `IFieldEncryptor` | reuse from **b0** | local symmetric key; encrypts `credential_number`, never logs plaintext. | reuse row | | `ICacheService` | reuse from **b0/b1** | in-memory; behind the typed config accessor + the step-type/badge caches. | reuse row | Each new seam is an **Application-layer interface** with an **Infrastructure mock** and **DI registration** via a `ServiceConfiguration/` extension (real impl is config-selected later — **never** an `if (mock)` branch in a handler). Persist the raw vendor response in `external_response_json` so the audit trail survives the swap. Append the three new rows to [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (seam, file, what's faked, config keys, **step-by-step how to make it real** — which Iranian KYC vendor [Finnotech / U-ID / Jibbit / Farashensa / Verify / Kavoshak], which methods to implement, what to test; and for `ICredentialVerifier`, the note that MoH/INO have **no public B2B API** so it stays manual until one appears). ## 5. Critical rules you must not get wrong - **`nurse_verifications.status` is the SINGLE source of truth.** `nurse_profiles.verification_status` no longer exists — **do not reintroduce it** or any second copy of verification state. Every read of "is this nurse verified for *state* purposes" goes through `nurse_verifications.status`; the only derived boolean is `nurse_profiles.is_verified`, written solely by the flip below. - **Flip `nurse_profiles.is_verified = 1` ONLY inside the all-passed transaction** that confirms every *required* step is `passed` (`AggregateAndFinalize`). Never set it from a controller, a partial pass, or an out-of-band update. **Reverse it (`is_verified = 0`) in the same transaction on suspension** — a suspended or lapsed nurse must immediately stop being bookable/searchable. - **A nurse is bookable only after all required steps pass.** Any failing/pending/expired required step keeps every service variant unbookable. This is the gate b7's `nurse_search_index.is_searchable` reads. - **The pipeline is data-driven — read required steps from `verification_step_types`, never a code enum.** A new step is an INSERT. Seed the six codes; don't branch on them in a `switch` that would break when a row is added. - **`step_type.is_automated` is SNAPSHOTTED onto `verification_steps` at seed time — read the snapshot, not the live step type.** Historical records must survive later catalog edits (an admin toggling a step's automation must not rewrite the meaning of past verifications). - **Expiring credentials auto-revert the step.** On `expires_at` lapse (criminal-record especially), revert the step to `pending`/`expired`, **raise a `support_alert`**, send a renewal prompt, and if a *required* credential lapsed, re-gate bookability. A lapsed certificate must **never** silently keep a nurse verified. - **`users.national_id` is populated only after the identity step passes**, and every downstream comparison (Shahkar match, IBAN ownership, credential holder-name cross-check) compares against **that** verified national-ID. An unverified registration must never look KYC-complete. - **IBAN ownership gates the first payout, not admin eyeballing.** `bank_account_verification` reuses b3's `IBankAccountOwnershipVerifier`; the holder national-ID must equal the verified nurse national-ID (money-mule prevention). Never pass the step on a mismatch. - **`verification_documents` store metadata only — bytes live in object storage behind signed URLs, with an integrity hash; files are never public.** No document byte stream is ever written to or returned from the DB; access is always a short-lived signed URL. - **`credential_number` is encrypted PII** (through `IFieldEncryptor`) and **`holder_name_snapshot` must be cross-checked against the nurse's identity name** before a credential is recorded as verified. Never trust an uploaded file alone — forgery (the "imposter nurse") is the documented attack. - **Shared-SIM is an explicit handled state**, not an undefined edge: fail Shahkar gracefully with a clear, non-accusatory reason and raise a `support_alert`. Re-run Shahkar on phone change (`shahkar_verified_at` resets to NULL upstream). - **Every manual admin decision is auditable** (the b1 audit interceptor + an explicit decision record) for defensibility — verification is platform-owned and must never be marketed as a check not actually performed. - **All vendor steps are mocked now — but behind real DI seams.** No mock behaviour baked into call sites; the swap is implementation-only and config-selected. - **Tenancy:** every nurse-side command/query is scoped to the authenticated nurse via `ICurrentUser`; a nurse can never read or act on another nurse's verification. Admin endpoints sit behind the admin policy and are rate-limited. The public trust badge exposes **credential types held, never numbers**. ## 6. Definition of Done The shared [definition-of-done.md](../_shared/definition-of-done.md), plus: - [ ] The five tables (`nurse_verifications`, `verification_step_types`, `verification_steps`, `verification_documents`, `nurse_credentials`) exist via one migration, each with its `IEntityTypeConfiguration`, the `nurse_id` UNIQUE (1:1) on `nurse_verifications`, the `UNIQUE(nurse_verification_id, step_type_id)` on `verification_steps`, the **snapshot `is_automated`** column, encrypted `credential_number`, and soft-delete/audit wiring. - [ ] The seed migration loads the **six** `verification_step_types` with the exact stable codes and the correct `is_required`/`is_automated` flags. - [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads, validators), with `NurseVerificationController`, `AdminVerificationController`, and the public badge. - [ ] **`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`** introduced (Application interfaces, Infrastructure mocks, DI via a `ServiceConfiguration/` extension, config-selected); the b3 `IBankAccountOwnershipVerifier` and b0 `IObjectStorage`/`IFieldEncryptor` seams reused (not redefined). No `if (mock)` in handlers. - [ ] The **flip transaction** is correct: `is_verified=1` only on all-required-passed, in one transaction; `is_verified=0` on suspension, in one transaction; the expiry scan reverts steps + raises alerts. - [ ] Handler unit tests (NSubstitute) for: step seeding from required step-types, the snapshot of `is_automated`, the automated-step pass/fail (Shahkar + identity KYC mocks), the manual-review → credential-record path with holder-name cross-check, the all-passed flip, the suspension reverse, and the expiry revert + alert. ≥1 `WebApplicationFactory` integration test per controller (happy path, 401, validation 400). `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green. - [ ] The `Baya.Application/Features/Verification/**` area is added to the **Project map** in `server/CLAUDE.md`; the three new seams noted where seams are documented. - [ ] The contract `dev/contracts/domains/verification.md` is written and the `swagger.json` snapshot republished. ## 7. How to test (what a human can verify after this phase) Prereqs: a nurse user with a `nurse_profiles` row (b3) and (for the bank step) a primary `nurse_bank_accounts` row; an admin user. The seam mocks are deterministic — use the seed/test national-IDs. 1. **Seed step-types** — `GET api/v1/admin_verification_step_types` → the **six** seeded codes appear with correct `is_required`/`is_automated` flags. Add a 7th via `POST` → it persists (proves data-driven). 2. **Submit** — as the nurse, `POST api/v1/nurse_verification/submit` → a `nurse_verifications` row in `pending` and **one `verification_steps` row per required step**, each with `is_automated` snapshotted. `GET api/v1/nurse_verification` shows the checklist + "what's blocking bookability". 3. **Upload a document** — `POST …/steps/{stepId}/upload_url` returns a signed URL; PUT a file to it (mock storage); `POST …/steps/{stepId}/documents` → a `verification_documents` **metadata row** with the `object_storage_key` + `integrity_hash` stored; **no bytes in the DB**; the manual step moves to `in_review`. 4. **Run automated Shahkar / identity KYC** — `POST …/steps/identity_kyc/run` (pass test national-ID) → step `passed`, `users.national_id` populated, `external_response_json` stored; then `POST …/steps/shahkar_match/run` → `passed` (and `shahkar_verified_at` set). Run Shahkar with the **shared-SIM** test number → step `failed` + a `support_alert` raised. 5. **Run bank-account verification** — `POST …/steps/bank_account_verification/run` (matching test IBAN) → `passed` + the account's `matched_national_id=1`; with the **mismatch** test IBAN → `failed`. 6. **Admin approves manual steps** — `GET api/v1/admin_verifications?status=in_review` shows the queue; `POST api/v1/admin_verifications/steps/{stepId}/decide` (pass) on MoH + INO + criminal-record → each records a `nurse_credentials` row (encrypted number, holder-name cross-check, expiry on the criminal record). Reject one with a reason → step `failed` and the nurse sees the reason. 7. **All steps passed flips `is_verified` in one transaction** — once every required step is `passed`, `nurse_verifications.status='approved'` **and** `nurse_profiles.is_verified=1` — confirm both changed together (verify there is no in-between state where the verification is approved but `is_verified` is still 0). 8. **Suspend reverses it** — `POST api/v1/admin_verifications/{id}/suspend` → `status='suspended'` **and** `is_verified=0` together. 9. **Expire a credential** — set a `nurse_credentials.expires_at` (criminal record) in the past, run `POST api/v1/admin_verifications/scan_expiring` → the matching step reverts to `pending`/`expired`, a `support_alert` is raised, and (since it's required) the nurse is re-gated. 10. **Public trust badge** — `GET api/v1/nurses/{nurseId}/trust_badge` → shows the verified status + credential **types** held; **no credential numbers** are ever returned. ## 8. Hand off & document (close the phase) - **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the `Features/Verification/**` area + the three new seams). If you discover/confirm a rule the product docs don't capture (e.g. the exact `in_review` vs `pending` step semantics, the renewal-prompt notification, or the trust-badge "types not numbers" rule), record it in [`product/business/02-nurse-verification.md`](../../../product/business/02-nurse-verification.md) / [`product/data-model/04-verification-and-credentials.md`](../../../product/data-model/04-verification-and-credentials.md) — don't invent rules. Note the new seam family in `server/CONVENTIONS.md` if it establishes a reusable pattern. - **Contract to write:** **`dev/contracts/domains/verification.md`** (per [`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the nurse endpoints (submit, status, upload-url, confirm-document, run identity/Shahkar/bank steps), the admin endpoints (step-type CRUD, pending-steps queue, detail, decide, suspend, scan-expiring), and the public trust-badge endpoint; the `VerificationStatus` / `VerificationStepStatus` enums and the six step-type `code`s; the step / step-type / document (signed-URL) / credential DTO shapes (**`credential_number` never serialized**; badge shows **types not numbers**); auth/tenancy/rate-limit notes; the side effects (`is_verified` flip, `support_alerts` raised, `users.national_id` populated). Republish the `swagger.json` snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f5-b6** consumes. - **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-6.md` (the verification pipeline is live, what f5 can now build — the nurse verification checklist, identity submit, document upload, credentials form, under-review state, trust badge; which endpoints/contracts are live; that all vendors are mocked behind `IShahkarVerifier`/`IIdentityKycProvider`/`ICredentialVerifier` and the reused b3 bank seam). Append to `backend/STATUS.md`, write `dev/shared-working-context/reports/backend-phase-6-report.md` (what was built, **what is now testable and exactly how** per §7, what is mocked + how to make it real, contracts produced, follow-ups: the expiry-scan cron, automated MoH/INO lookup, `fraud_flags`), and update `dev/shared-working-context/reports/mocks-registry.md` (the three new seam rows → 🟡; the reused rows noted). - **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — `status` is the single source of truth (no `verification_status` copy), the guarded `is_verified` flip/reverse transaction, the `is_automated` snapshot rule, the data-driven step catalog, the expiry-revert + alert flow, documents-as- metadata-only, and the three new vendor seams — with a one-line pointer in `MEMORY.md`.