backend phase 6: nurse verification & credentials (mocked vendors)

The trust engine. New `verif` schema (5 tables) + a data-driven verification
pipeline: steps are rows (6 seeded step-types), not a code enum.

- nurse_verifications.status is the single source of verification truth;
  nurse_profiles.is_verified is flipped ONLY inside the finalize transaction
  (VerificationAggregator: tracked verification + tracked profile -> one commit)
  and reversed on suspension/expiry — no in-between state.
- is_automated snapshotted onto each step at submit; steps seeded from active
  required step-types; automated runs (identity-KYC, Shahkar, IBAN ownership)
  find their step by code.
- users.national_id populated only on identity-KYC pass; Shahkar + IBAN owner
  compare against it (money-mule guard); shared-SIM -> shared_sim support alert.
- Documents are metadata-only behind signed URLs; credential_number encrypted
  and never serialized; public trust badge exposes credential TYPES, not numbers;
  holder-name cross-checked against the verified identity before recording.
- Admin-triggered credential-expiry scan reverts lapsed steps, re-gates
  bookability, raises a verification_expired alert + verification_expiry_prompt
  notification (scheduled cron deferred; config key
  verification_expiry_scan_cadence_hours).

Three new mock vendor seams (IShahkarVerifier / IIdentityKycProvider /
ICredentialVerifier) behind DI; reuses b3 IBankAccountOwnershipVerifier and
b0 IObjectStorage/IFieldEncryptor. 15 endpoints across 4 controllers.

Two migrations (tables + step-type seed). 154 tests pass, zero new warnings.
Contract dev/contracts/domains/verification.md + swagger snapshot refreshed;
handoff/report/mocks-registry updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-05 14:39:32 +03:30
parent 687fbfc6d9
commit 1c266523bc
105 changed files with 13938 additions and 10 deletions
@@ -0,0 +1,126 @@
# Backend Phase 6 — Nurse verification & credentials (mocked vendors) — report
**Status:** complete · build clean (0 new code warnings) · `dotnet test Baya.sln` green (**153 pass**).
**Vendors are mocked** (three new DI seams + reused b3/b0 seams) — see [What is mocked](#what-is-mocked).
## What was built
The trust engine the whole marketplace gates on — a data-driven verification pipeline, the admin review
queue, the structured credential registry, the transactional `nurse_profiles.is_verified` flip, an
admin-triggered credential-expiry scanner, and the public trust badge. One additive migration (new **`verif`**
schema, **5 tables**), **15 endpoints across 4 controllers**, and **3 new mock vendor seams**.
- **Schema** (new **`verif`** schema): `nurse_verifications` (the header; `status` = the **single source of
verification truth**), `verification_step_types` (the seeded catalog of possible steps),
`verification_steps` (one per required step-type per nurse; snapshots `is_automated`),
`verification_documents` (metadata only — **bytes never in the DB**), `nurse_credentials`
(`credential_number` **encrypted**, never serialized). `nurse_profiles.is_verified` is the **only derived
boolean**, flipped **only inside the finalize transaction**.
- **Six seeded, stable step-type codes:** `identity_kyc`, `shahkar_match`, `moh_competency_license`,
`ino_membership`, `criminal_record`, `bank_account_verification` (three of them credential-bearing).
- **Nurse pipeline** (`NurseVerificationController`, `[Authorize]` + nurse role in handler, tenancy-scoped):
`submit` (upsert header + seed one step per active required step-type; **idempotent**), `GET` status (the
checklist + aggregate + `blockingSteps` + `isBookable`), `upload_url` / `documents` (manual steps →
`in_review`), and three automated `/run` endpoints — `identity_kyc` (populates `users.national_id` +
`national_id_verified_at`), `shahkar_match` (requires KYC; **shared-SIM = explicit handled failure**
`shared_sim` alert; sets `shahkar_verified_at`), `bank_account_verification` (reuses the b3
`IBankAccountOwnershipVerifier`; **money-mule guard** — holder national id must equal the verified nurse
national id; on match sets `matched_national_id=1`).
- **Admin step-type catalog** (`AdminVerificationStepTypesController`, dynamic-permission, `sensitive`
rate-limit): cached list (generation token), upsert (snake_case `code`, immutable once in use, dup →
**409**), deactivate (`is_active=false`, **never hard delete**).
- **Admin review** (`AdminVerificationsController`, dynamic-permission, `sensitive` rate-limit): queue
(`in_review` default; docs carry **signed GET URLs**), case detail (steps + docs + credentials + identity
name), `decide` (manual steps; on approving a credential-bearing step records an **encrypted**
`nurse_credentials` row, **holder-name cross-check** vs identity → mismatch **400** with no credential,
`criminal_record` requires `expiresAt`; writes an `audit_logs` record; **re-aggregates**, may flip
`is_verified`), `suspend` (`status=suspended` + **reverse `is_verified=0` in one transaction** + audit),
and `scan_expiring` (reverts lapsed steps → `expired`, `verification_expired` alert +
`verification_expiry_prompt` notification, re-gates bookability).
- **Public trust badge** (`NursesController`, `[AllowAnonymous]`): `GET nurses/{id}/trust_badge` returns
`isVerified` + `approvedAt` + the **credential TYPES held** (never numbers); cached (short TTL, evicted on
suspension/expiry/decision); `404` unknown nurse.
- **The scan logic ships** as `ScanExpiringCredentialsCommand`; the **scheduled cron is deferred** — the
admin `scan_expiring` endpoint is the entry point (config `verification_expiry_scan_cadence_hours`, int,
default `24`).
## What is now testable — and exactly how (the phase §7 steps)
Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger/curl.
The seam mocks are **deterministic** — use the test national ids / IBAN / phone below.
1. **Open the checklist** — as a nurse (with a b3 nurse profile), `POST /api/v1/nurse_verification/submit`
`200`; `GET /api/v1/nurse_verification``status: "pending"`, `isBookable: false`, one seeded step per
active required step-type, each with its `isAutomated`.
2. **Identity KYC passes**`POST /api/v1/nurse_verification/steps/identity_kyc/run`
`{ "nationalId": "1234567891" }``stepStatus: "passed"`; the nurse's `users.national_id` +
`national_id_verified_at` are populated. The configured fail id `0000000000``stepStatus: "failed"` +
`failureReason` (still `200`).
3. **Shahkar match**`POST /api/v1/nurse_verification/steps/shahkar_match/run` (no body) → `passed`;
`shahkar_verified_at` set. A nurse on the shared-SIM phone `09120000000`**handled failure** + a
`shared_sim` support alert row. Running it **before** KYC → **400**.
4. **Bank ownership** — with a **primary** `nurse_bank_accounts` row (b3),
`POST /api/v1/nurse_verification/steps/bank_account_verification/run` → on match sets
`matched_national_id=1`; the mismatch IBAN `IR000000000000000000000000` → failed. No primary account → **400**.
5. **Manual document upload** — for a credential step, `POST steps/{stepId}/upload_url`
`{ "contentType": "application/pdf" }``{ objectStorageKey, uploadUrl }`; PUT the bytes to `uploadUrl`;
`POST steps/{stepId}/documents` `{ objectStorageKey, integrityHash, contentType, fileSizeBytes }` → the
step moves to `in_review` and a metadata row is stored (no bytes in the DB). An automated step rejects the
upload → **400**.
6. **Admin review queue** — as admin, `GET /api/v1/admin_verifications` (default `status=in_review`) →
the pending step with **signed GET URLs** on its documents; `GET /api/v1/admin_verifications/{id}` → the
full case incl. the identity name for cross-check.
7. **Approve a credential**`POST /api/v1/admin_verifications/steps/{stepId}/decide`
`{ approve: true, credentialNumber, holderName: "<verified identity name>", issuingAuthority, issuedAt }`
→ records an **encrypted** credential (`credentialId` returned; the number is never serialized back),
writes an audit record, re-aggregates. A **holder-name mismatch → 400** with **no** credential recorded;
a `criminal_record` approval **without `expiresAt` → 400**; `approve:false` without `rejectionReason`
**400**.
8. **is_verified flips** — once every required step is `passed`/`approved`, the re-aggregate flips
`nurse_profiles.is_verified=1` **in one transaction**; `GET /api/v1/nurse_verification` now reads
`status: "approved"`, `isBookable: true`.
9. **Public trust badge**`GET /api/v1/nurses/{nurseId}/trust_badge``isVerified: true`, `approvedAt`,
`credentialTypes: ["moh_competency_license", …]` (**types only**, never numbers). Unknown nurse → **404**.
10. **Suspend & expiry**`POST /api/v1/admin_verifications/{id}/suspend` `{ reason }` sets
`status=suspended` and **reverses `is_verified=0`** in one transaction (+ audit); the trust badge
updates. `POST /api/v1/admin_verifications/scan_expiring` reverts lapsed time-limited steps to `expired`,
raises a `verification_expired` alert + `verification_expiry_prompt` notification, and re-gates
bookability (`ScanExpiringResult{ scannedSteps, revertedNurses }`).
## What is mocked / waiting on a real service
Three **new** seams (all deterministic, all mocked here) + three **reused** seams:
- `IShahkarVerifier``MockShahkarVerifier` — phone↔national-id match: **pass** unless the shared-SIM phone
`09120000000` (→ shared-SIM handled failure) or the mismatch national id `1111111111`. Registry row:
`IShahkarVerifier` in [`mocks-registry.md`](mocks-registry.md).
- `IIdentityKycProvider``MockIdentityKycProvider` — national-id + liveness: passes any well-formed 10-digit
national id **except** the configured fail id `0000000000`. Registry row: `IIdentityKycProvider`.
- `ICredentialVerifier``MockCredentialVerifier` — MoH/INO/criminal-record: the manual-admin default
(always `RequiresManualReview` / `verification_method=manual`). Registry row: `ICredentialVerifier`.
- **Reused:** `IBankAccountOwnershipVerifier` (b3 — mismatch IBAN `IR000000000000000000000000`),
`IObjectStorage` (b0 — local-disk signed PUT/GET URLs), `IFieldEncryptor` (b0 — encrypts
`credential_number`).
## Contracts
- **Produced:** [`dev/contracts/domains/verification.md`](../../contracts/domains/verification.md) (all 15
routes, DTO shapes, the four enums, failure cases, side effects, mock test values, worked example).
`swagger.v1.json` **refreshed** — [`dev/contracts/openapi/swagger.v1.json`](../../contracts/openapi/swagger.v1.json)
includes all 15 b6 endpoints.
- **Consumed:** `nurse_profiles` + `IBankAccountOwnershipVerifier` (b3), `users.national_id` /
`shahkar_verified_at` (b2), `IObjectStorage` / `IFieldEncryptor` / `ICacheService` / CQRS /
`OperationResult` / `BaseController` (b0), dynamic-permission + `support_alerts` / `notifications` /
`audit_logs` + `sensitive` rate-limit (b1).
## Docs updated
- New contract `dev/contracts/domains/verification.md`; openapi snapshot refreshed.
- Handoff `backend/handoff/after-backend-phase-6.md`; status log `backend/STATUS.md` (append).
- The three b6 seams (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`) are the
pre-listed rows in `reports/mocks-registry.md` — now realized as seam + fake impl.
## Follow-ups for later phases
- **Scheduled expiry cron** — a `CredentialExpiryScannerJob` hosted scheduler that calls the shipped
`ScanExpiringCredentialsCommand` on `verification_expiry_scan_cadence_hours` cadence. **Deferred** — the
admin `scan_expiring` endpoint is the manual entry point today.
- **Automated MoH/INO license lookup** — behind `ICredentialVerifier` (`verification_method=api`) once a
portal exists. **Deferred.**
- **`fraud_flags` / ML fraud scoring** — **deferred.**
- **Professional-liability-insurance step** — addable later as a `verification_step_types` row (no schema
change). **Deferred.**
- **b7 (search & matching)** — reads `nurse_profiles.is_verified` for `nurse_search_index.is_searchable`;
b6 owns the flip, b7 owns the index.
@@ -23,9 +23,9 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| `IHolidayCalendar` | backend-phase-1 | Bank holidays — reads the seeded `ops.IranianHolidays` table; lookups cached (`HolidayCalendarService`, `Persistence/Services/Holidays/`); Iranian banking weekend = Friday | _none_ | Add a sync job/feed that maintains the (partly lunar-Hijri) calendar table; the read interface stays | 🟡 |
| `IAnalyticsSink` | backend-phase-1 | Behavioural events — inserts an `ops.SystemEvents` row, fire-and-forget (`AnalyticsSink`, `Persistence/Services/Analytics/`) | _none_ | Pipe to a warehouse/stream (e.g. Kafka→ClickHouse); keep fire-and-forget semantics | 🟡 |
| `IJobScheduler` (retention) | backend-phase-1 | Scheduling — in-process interval `BackgroundService` running `PurgeOldReadNotifications` daily (`NotificationRetentionHostedService`, `Persistence/Services/Notifications/`) | _none_ | Swap to Hangfire/Quartz; register the job there; keep the purge predicate (`is_read=1 AND age>90d`) | 🟡 |
| `IShahkarVerifier` | backend-phase-6 | Phone↔national-id match — fake pass | _tbd_ | Real Shahkar/KYC vendor; persist `external_response_json` | 🔴 |
| `IIdentityKycProvider` | backend-phase-6 | National-ID + liveness — fake pass | _tbd_ | Finnotech/U-ID/Jibbit/Verify liveness+OCR | 🔴 |
| `ICredentialVerifier` | backend-phase-6 | MoH/INO/criminal-record — manual/fake | _tbd_ | Manual admin today; API when a portal appears (`verification_method=api`) | 🔴 |
| `IShahkarVerifier` | backend-phase-6 | شاهکار phone↔national-id binding — `MockShahkarVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic result + fake vendor ref + `external_response_json`: matches every pair except the configured shared-SIM phone (→ the explicit shared-SIM failure state, which the handler turns into a `shared_sim` support alert) and the mismatch national id (→ plain mismatch); registered singleton in `AddCrossCuttingSeams`. No real Shahkar call | `Seams:Shahkar:SharedSimPhone` (default `09120000000`), `Seams:Shahkar:MismatchNationalId` (default `1111111111`) | 1) pick a Finnotech / KYC Shahkar-bridge vendor, add its client package to `Directory.Packages.props`; 2) add `Seams:Shahkar:{ApiKey,BaseUrl}` options; 3) implement `MatchAsync(phone, nationalId)` against the real استعلام شاهکار, mapping to `ShahkarMatchResult` and persisting the raw response into the step's `external_response_json`; 4) keep shared-SIM as the explicit handled failure (`IsSharedSim=true`); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match / shared-SIM / mismatch + that a phone change re-runs it (`shahkar_verified_at` resets upstream on phone change) | 🟡 |
| `IIdentityKycProvider` | backend-phase-6 | Identity KYC (national-id validity + name match + liveness)`MockIdentityKycProvider` (`.../Seams/`) passes any well-formed 10-digit national id except the configured fail id, returning a matched name + fake vendor ref + `external_response_json`; on pass the handler populates `users.national_id` + `national_id_verified_at`. No real OCR/liveness; registered singleton | `Seams:IdentityKyc:FailNationalId` (default `0000000000`), `Seams:IdentityKyc:MatchedName` (default `Verified Nurse`) | 1) pick an Iranian e-KYC vendor (Finnotech / U-ID / Jibbit / Farashensa / Verify / Kavoshak), add its client package to `Directory.Packages.props`; 2) add `Seams:IdentityKyc:{ApiKey,BaseUrl}` options; 3) implement `VerifyAsync(nationalId, livenessPayload)` → national-id validity + name match + photo/video liveness against ثبت احوال, mapping to `IdentityKycResult` and persisting `external_response_json`; 4) swap the registration (config-selected) — handlers unchanged; 5) test pass/fail by national id + that `national_id` is populated **only** on pass | 🟡 |
| `ICredentialVerifier` | backend-phase-6 | MoH پروانه صلاحیت حرفه‌ای / INO / عدم سوء پیشینه verification — `MockCredentialVerifier` (`.../Seams/`) **is** the manual-admin default: every call returns `RequiresManualReview` with `verification_method=manual` (an admin verifies the uploaded document against the official portal in `AdminReviewStep`). No portal call; registered singleton. There is **no public B2B API** for MoH/INO, so this stays manual until one appears | _none_ | 1) when an MoH/INO portal or API becomes available, implement `VerifyAsync(credentialType, credentialNumber)` to return `Verified`/`Failed` with `verification_method=portal|api` (+ `external_response_json`); 2) swap the registration (config-selected) for those credential types — the manual path stays the fallback; 3) the structured `nurse_credentials` registry already stores number/authority/expiry so cross-check + renewal survive the swap. **MoH/INO have no public B2B API today** | 🟡 |
| `IBankAccountOwnershipVerifier` | backend-phase-3 | استعلام شبا IBAN-owner ↔ national-id inquiry — `MockBankAccountOwnershipVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic fake: every IBAN matches (`matched_national_id=true`, echoes a holder name + `MOCK-SHEBA-{sha}` vendor ref) except the configured mismatch IBAN which returns `false`; registered singleton in `AddCrossCuttingSeams`. No real bank/KYC call, no money moves | `Seams:BankOwnership:MismatchIban` (default `IR000000000000000000000000`), `Seams:BankOwnership:MatchedHolderName`, `Seams:BankOwnership:MismatchHolderName` | 1) pick a Finnotech / banking-bridge استعلام شبا provider, add its client package to `Directory.Packages.props`; 2) add `Seams:BankOwnership:{ApiKey,BaseUrl}` options; 3) implement `VerifyOwnershipAsync(iban, nurseNationalId)` against the real Sheba-owner inquiry, mapping to `OwnershipInquiryResult`; 4) persist the real `ownership_vendor_ref` (+ raw response if a column is added); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match/mismatch + that the b13 first-payout gate honours `matched_national_id=true` | 🟡 |
| `IGeocoder` | backend-phase-4 | Address→lat/lng — `MockGeocoder` (`Baya.Infrastructure.CrossCutting/Seams/`) returns deterministic `decimal` coordinates jittered (FNV-1a, ~±5 km) around the known city centroid (unknown city → Iran centroid) plus `formatted_address` + `confidence`; **no network call**. A global switch or a per-address marker forces the null-coordinate ("no map pin") path; registered singleton in `AddCrossCuttingSeams` | `Seams:Geocoding:ReturnNullCoordinates` (default `false`), `Seams:Geocoding:LowConfidenceMarker` (default `NO_GEO`), `Seams:Geocoding:ResolvedConfidence` (default `0.9`) | 1) pick Neshan (or Google) geocoding, add its client package to `Directory.Packages.props`; 2) add `Seams:Geocoding:{ApiKey,BaseUrl}` options; 3) implement `IGeocoder.GeocodeAsync(addressText, cityName, districtName?)` against it, mapping to `(lat, lng, formatted_address, confidence)` with `decimal` coords; 4) add rate-limit/retry; 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test a known Tehran address resolves within expected bounds | 🟡 |
| `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoice — leaves ref pending | _tbd_ | Real مودیان submission → 22-digit ref | 🔴 |
@@ -48,3 +48,7 @@ the frontend can build before the backend phase merges, and swap to the real HTT
| `ProfilesApi` | `client/src/services/profiles/apis/mockApi.ts` | Customer + nurse profile get/upsert and **avatar upload** (echoes an object-URL). Keeps guarded read-only fields (`isVerified=false`, zero aggregates). Augments customer name/language (REQ-007) + nurse `avatarUrl` (REQ-006) the wire DTOs lack | `USE_PROFILES_MOCK` (`services/profiles/constants.ts`, default `true`) | b3 `customer_profiles/*` + `nurse_profiles/*` are live; deliver REQ-006 (avatar route/field) + REQ-007 (customer name/language) then set flag `false``profilesClientApi` is wired (its `uploadAvatar` throws `501` until REQ-006) | 🟡 |
| `NurseBankAccountsApi` | `client/src/services/nurse/apis/mockApi.ts` | Bank-account list/add/set-primary/verify-ownership. Drives the استعلام شبا **pending→verified/mismatch** transition over 2 list reads (so the poll shows it), single-primary enforcement, masked-IBAN (last-4); the configured mismatch IBAN (`IR000000000000000000000000`, matches backend default) resolves to `matchedNationalId=false` | `USE_NURSE_BANK_MOCK` (`services/nurse/constants.ts`, default `true`) | b3 `nurse_bank_accounts/*` are live (the real `add` resolves the inquiry synchronously — no client poll needed); set flag `false``nurseBankClientApi` is wired | 🟡 |
| `AuthApi` | `client/src/services/auth/apis/mockApi.ts` (`authMockApi`) | Phone-OTP login offline: `requestOtp``{otpSent,resendAvailableInSeconds:120}`; `verifyOtp` accepts dev code **`123456`** and locks after 3 wrong tries (`otp_locked`); `getMe`/`selectRole`/`refresh` from a `MOCK_SCENARIO` toggle (`customer`/`nurse_unverified`/`no_role`) to exercise all router branches | `USE_AUTH_MOCK` (`services/auth/constants.ts`, default **false** — b2 is live) + `MOCK_SCENARIO` in `mockApi.ts` | The real `authClientApi` is already wired to the live b2 routes; set `USE_AUTH_MOCK = false` (already the default) — no hook/screen change | 🟢 real by default, 🟡 mock available |
| `GeographyApi` | `client/src/services/geography/apis/mockApi.ts` (+ `apis/seed.ts`) | The province→city→district reference hierarchy — a faithful subset of the b4 seed: 8 provinces, Tehran (city 101) with its 22 مناطق (1001…1022), and the white-space cities Mashhad/Isfahan/Shiraz/Tabriz/Ahvaz/Qom/Karaj as whole-city-only. Active-only, `sortOrder`-ordered. `seed.ts` also resolves a saved `cityId`/`districtId` back to names for the addresses & serviceAreas mocks | `USE_GEOGRAPHY_MOCK` (`services/geography/constants.ts`, default `true`) | b4 `geo/{provinces,cities,districts}` are live; set flag `false``geographyClientApi` is wired to the snake_case-param lookups. No hook/component change | 🟡 |
| `AddressesApi` | `client/src/services/addresses/apis/mockApi.ts` | Customer address CRUD (list primary-first / create / update / set-primary / soft-delete) with the **exactly-one-primary** invariant enforced in-memory (first address auto-primary; promoting clears the prior; deleting the primary promotes the next). Persists the client-augmented `provinceId` (REQ-009) and the picked `latitude`/`longitude` (REQ-008) the wire DTO/create-body lack | `USE_ADDRESSES_MOCK` (`services/addresses/constants.ts`, default `true`) | b4 `customer_addresses/*` are live; deliver REQ-008 (accept the pin) + REQ-009 (`provinceId` on the DTO), then set flag `false``addressesClientApi` is wired (sends the pin + `pageSize`, echoes `provinceId` locally) | 🟡 |
| `ServiceAreasApi` | `client/src/services/serviceAreas/apis/mockApi.ts` | Nurse coverage areas (list whole-city-first / add / remove). Enforces `UNIQUE(cityId, districtId)` exactly as the server — a duplicate (incl. a second whole-city row) throws the same **`409`** (`area_duplicate`) so the coverage editor's inline dup handling is demonstrable | `USE_SERVICE_AREAS_MOCK` (`services/serviceAreas/constants.ts`, default `true`) | b4 `nurse_service_areas/*` are live; set flag `false``serviceAreasClientApi` is wired (maps the server 409 to the same inline message). No hook/component change | 🟡 |
| `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange``AddressForm` and every caller stay unchanged | 🟡 |