# 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 |
