add build development phases

This commit is contained in:
hamid
2026-06-28 21:59:59 +03:30
parent 1df3cd9f64
commit 53a40dc51d
52 changed files with 12379 additions and 0 deletions
+353
View File
@@ -0,0 +1,353 @@
# 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<T>`, 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/<Area>/{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}/<Name>/`; entities in
`Baya.Domain/Entities/Verification/`; one `IEntityTypeConfiguration<T>` 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<T>`, 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`.