Files
baya-monorepo/dev/phases/backend/backend-phase-6.md
T
2026-06-28 21:59:59 +03:30

34 KiB

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 (nurse_profiles, nurse_bank_accounts, IBankAccountOwnershipVerifier), b1 (support_alerts, the typed config accessor), b0 (IObjectStorage, IFieldEncryptor, ICurrentUser, audit interceptor) · Unlocks: search visibility (b7); frontend f5-b6 Before you start, read ../_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, which built the bookable service variants) and search (b7, 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_profilesb3 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 CUTnurse_verifications.status is the sole source of verification truth. Do not reintroduce a second copy.
  • nurse_bank_accountsb3 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 / identityb2/b3: 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_alertsb1 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_configsb1. Read review-SLA hints, expiry-scan cadence, and any verification thresholds through it; never hardcode.
  • IObjectStorageb0: presigned/streamed put/get/delete keyed by an opaque storage key, returning a retrievable URL. Verification documents store bytes here. Reuse it.
  • IFieldEncryptorb0: 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 and ../_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.mdthe 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.mdthe canonical schema for the five tables. Mirror these field names exactly (especially the nurse_credentials column list and the UNIQUE constraints).
  • product/research/verification.mdwhy 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's nurse_bank_accounts config + the IBankAccountOwnershipVerifier usage; b1's support_alerts raise API + the typed config accessor + the seed-migration pattern (for verification_step_types); b0'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 (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 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 (pendingin_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 (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, 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-typesGET 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 documentPOST …/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 KYCPOST …/steps/identity_kyc/run (pass test national-ID) → step passed, users.national_id populated, external_response_json stored; then POST …/steps/shahkar_match/runpassed (and shahkar_verified_at set). Run Shahkar with the shared-SIM test number → step failed + a support_alert raised.
  5. Run bank-account verificationPOST …/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 stepsGET 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 itPOST api/v1/admin_verifications/{id}/suspendstatus='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 badgeGET 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 (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/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) — 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 codes; 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. 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.