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

30 KiB

Backend Phase 4 — Geography, addresses & nurse service areas

Mission: lay the geographic spine the whole marketplace stands on. Build the province → city → district reference hierarchy (as tables, not hardcoded lists, so new regions launch without a deploy), seed it with Iran's provinces, major cities, and Tehran's 22 municipal districts; let admins curate it with is_active/sort_order toggles; serve the cascading dropdowns that every address form and search filter needs; let a nurse declare where they will travel (nurse_service_areas, city or city+district, district_id=NULL meaning the whole city); and let a customer save service addresses with a single enforced primary, encrypted at rest, and geocoded coordinates behind the IGeocoder seam. After this phase, catalog/search and booking have the geography they consume.

Track: backend · Depends on: b3 (nurse_profiles, customer_profiles), b0 (the IGeocoder host, IFieldEncryptor, REST surface, audit/caching seams), b1 (the seed pattern + typed config accessor) · Unlocks: catalog/search (b5/b7), booking (b8); frontend f3-b4 Before you start, read ../_shared/agent-operating-rules.md. It is not optional.


1. Context — where this sits

This is backend phase b4, a near-root reference domain that almost everything downstream consumes. Balinyaar matches families to nurses by named place, not by GPS radius: a customer's saved address lives in a city (and optionally a district), a nurse declares the cities/districts they will travel to, and search later intersects the two. The geo hierarchy is stored in tables (not a static code list) precisely so a new city or district can be launched by an admin insert — and so is_active / sort_order give ordered, toggleable dropdowns without ever deleting a region that has historical rows pointing at it. This phase builds that hierarchy, the two membership tables that hang off it (nurse_service_areas, customer_addresses), and the IGeocoder seam that turns a typed address into the lat/lng EVV will later check against.

What already exists (do not rebuild) — built by prior phases:

  • nurse_profiles & customer_profilesb3 built the role profile extensions off users (nurse_profiles.id 1:1 with a nurse user; customer_profiles.id 1:1 with a customer user), plus patients and nurse_bank_accounts and their tenancy rules. nurse_service_areas hangs off nurse_profiles; customer_addresses hangs off customer_profiles. Reuse those FKs — do not re-create the profiles.
  • The IGeocoder hostb0 reserved the registry row for the geocoding seam (it is introduced here, see §4). b0 also built IFieldEncryptor (the encrypt/decrypt + deterministic Hash used by every encrypted PII column — addresses use it), ICacheService (the in-memory cache the geo-lookup queries read through), IDateTimeProvider, ICurrentUser + the audit-field SaveChanges interceptor, the REST controller surface (sealed : BaseController, ISender, base.OperationResult(...), snake_case routes), rate limiting, and LoggingBehavior.
  • The seed + typed-config patternb1 established how reference/seed data is loaded into the marketplace baseline (the first migration baseline, the seeding mechanism, the typed cached platform_configs accessor). Mirror b1's seeding mechanism for the province/city/district seed — do not invent a parallel one.
  • The b0 foundation generally: Clean-Arch projects, CQRS via martinothamar/Mediator (ISender, ICommand/IQuery, internal sealed handlers), OperationResult<T>, Mapster, FluentValidation, IUnitOfWork, soft-delete query filters, the ServiceConfiguration/ registration convention.

What this phase introduces: the five tables below, the admin/public/nurse/customer capabilities over them, and one new seam — IGeocoder (the mocked maps/geocoding provider). No GPS-radius model, no map-tile rendering, no availability — those are out of scope (see DEFERRED tags throughout).

2. Required reading (do this first)

  • ../_shared/agent-operating-rules.md and ../_shared/backend-conventions-checklist.md — especially Persistence (projection + pagination, one IEntityTypeConfiguration<T> per entity, soft-delete filters, encrypted PII columns go through the field encryptor seam) and Performance, caching (read-heavy reference data is cached behind the cache seam with sensible invalidation).
  • product/data-model/02-geography.mdthe canonical geo schema: provinces 1:N cities 1:N districts with sort_order/is_active; districts are optional (Tehran's 22 مناطق / neighborhoods elsewhere); nurse_service_areas with district_id=NULL = whole city and UNIQUE(nurse_id, city_id, district_id); named districts, not a GPS radius.
  • product/data-model/01-identity-and-access.md — the customer_addresses definition (encrypted address + coordinates for EVV, filtered UNIQUE(customer_id) WHERE is_primary=1) and how nurse_service_areas relates to nurse_profiles.
  • product/business/04-search-and-matching.mdwhy this exists: geography is driven by nurse-declared service areas; a city-level row (no district) means the whole city; a city search must later match both city-only rows and any district row in that city; districts are optional and vary (Tehran's 22 official مناطق vs neighborhoods elsewhere). This is the consumer that makes district_id=NULL semantics load-bearing — get them right here.
  • The digests (authoritative per-domain detail): the Geography and Identity & Access → customer_addresses/nurse_service_areas sections of dm_identity_geo_services_verif.md, and the Search & Matching → geo section of biz_catalog_search.md (in the run's digests/).
  • Code to mirror: b3's nurse_profiles/customer_profiles configs and the customer/nurse feature command structure; b1's seeding mechanism + the typed cached config accessor + the first migration baseline it created (you add one migration on top); b0's IFieldEncryptor usage on PII columns, the ICacheService GetOrCreateAsync pattern, and any ServiceConfiguration/ seam registration.
  • Contract conventions: ../../contracts/conventions/api-conventions.md (envelope, snake_case routes, pagination shape) and money-and-types.md (not money here, but the id/type conventions — ids are BIGINT, coordinates are decimal, never float).
  • Prior handoffs: dev/shared-working-context/backend/handoff/after-backend-phase-3.md, …-1.md, …-0.md, and reports/mocks-registry.md (the IGeocoder row you flip from reserved → 🟡).

3. Scope — build this

Features live under Baya.Application/Features/Geography/{Commands|Queries}/<Name>/, Baya.Application/Features/ServiceAreas/{Commands|Queries}/<Name>/, and Baya.Application/Features/Addresses/{Commands|Queries}/<Name>/. Entities go in Baya.Domain/Entities/Geography/ (provinces/cities/districts/nurse_service_areas) and Baya.Domain/Entities/Identity/ (customer_addresses, next to b3's profiles — it is an identity-domain table per the data model). One IEntityTypeConfiguration<T> per entity in Persistence/Configuration/GeographyConfig/ and …/IdentityConfig/. One EF migration for the five tables. Ids are BIGINT; coordinates are decimal(9,6) (never float/double).

3.1 Entities + migration

provinces [CORE] — top of the geo hierarchy.

  • Fields: id (BIGINT PK), name_fa, name_en (NVARCHAR — Persian primary, both required), sort_order (int, default 0), is_active (BIT, default 1), audit fields, deleted_at (soft-delete).
  • Relations: 1:N → cities.

cities [CORE] — the main address/search granularity.

  • Fields: id (BIGINT PK), province_id (FK → provinces), name_fa, name_en, sort_order, is_active, audit fields, deleted_at.
  • Index: (province_id, sort_order) for the ordered cascading lookup.
  • Relations: N:1 → provinces; 1:N → districts, nurse_service_areas, customer_addresses.

districts [CORE]/[MVP] — Tehran's 22 municipal مناطق / major neighborhoods elsewhere; optional.

  • Fields: id (BIGINT PK), city_id (FK → cities), name_fa, name_en, sort_order, is_active, audit fields, deleted_at.
  • Index: (city_id, sort_order).
  • Relations: N:1 → cities; 1:N → nurse_service_areas, customer_addresses (both nullable refs).

nurse_service_areas [CORE] — where a nurse will travel; the membership row search later intersects.

  • Fields: id (BIGINT PK), nurse_id (FK → nurse_profiles), city_id (FK → cities), district_id (FK → districts, NULLABLE), is_active (BIT, default 1), audit fields, deleted_at.
  • Constraint: UNIQUE(nurse_id, city_id, district_id) — and it must include the NULL district_id rows. On SQL Server a plain unique index treats NULLs as distinct, which would wrongly allow two "whole city" rows for the same nurse+city. Enforce the whole-city uniqueness deliberately: either a filtered unique index UNIQUE(nurse_id, city_id) WHERE district_id IS NULL plus UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL, or a computed/sentinel column so the single index covers both. Whichever you pick, a duplicate "whole city" and a duplicate "city+district" are both rejected. (See §5 — this is the rule most easily gotten wrong.)
  • Relations: N:1 → nurse_profiles, cities, districts.

customer_addresses [CORE] — saved service locations; encrypted address + coordinates for EVV.

  • Fields: id (BIGINT PK), customer_id (FK → customer_profiles), city_id (FK → cities), district_id (FK → districts, NULLABLE), title (NVARCHAR — "خانه"/"محل کار", a label, not PII), address_line (encrypted via IFieldEncryptor), postal_code (encrypted, nullable), latitude / longitude (decimal(9,6), nullable until geocoded), is_primary (BIT, default 0), recipient_name / recipient_phone (encrypted, nullable), audit fields, deleted_at.
  • Constraint: filtered UNIQUE(customer_id) WHERE is_primary=1 — exactly one primary per customer, enforced at the DB level (the authoritative backstop), not only in the handler.
  • Relations: N:1 → customer_profiles, cities, districts; later referenced by booking_requests/bookings (b8/b9 — DEFERRED here, just don't preclude it).

Soft-delete & audit: every table declares the global deleted_at IS NULL query filter and inherits audit-field stamping from b0's interceptor — handlers never set CreatedAt/CreatedById. Geo rows are deactivated (is_active=0) far more often than deleted, so toggled-off regions vanish from dropdowns without orphaning the historical addresses/areas that point at them.

3.2 Seed data (mirror b1's seeding mechanism)

Add a province/city/district seed that runs as part of the b1-style seeding path (idempotent — safe to re-run; key on a stable natural identifier such as name_en within parent, not on auto id):

  • All 31 Iranian provinces (name_fa/name_en, sort_order by Iranian convention with Tehran first or alphabetical Persian — keep it deterministic).
  • Major cities at minimum the white-space targets the product calls out: Tehran, Karaj, Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom (plus each province's capital).
  • Tehran's 22 municipal districts (منطقه ۱ … منطقه ۲۲) under the Tehran city row. Other cities get no districts at seed time (whole-city coverage is the default and is meaningful — see §5). Adding neighborhoods elsewhere is an admin insert later, no deploy.

3.3 Commands & queries (CQRS, OperationResult, never throw for expected failures)

Capability Type Route What it does
ListProvincesQuery Query GET api/v1/geo/provinces Active provinces ordered by sort_order. AsNoTracking + .Select to a ProvinceDto; cached via ICacheService.GetOrCreateAsync (reference data; invalidate on admin write). Public.
ListCitiesQuery Query GET api/v1/geo/cities?province_id= Active cities for a province, ordered by sort_order. Projected + cached + paginated. Public.
ListDistrictsQuery Query GET api/v1/geo/districts?city_id= Active districts for a city, ordered. Empty list is a valid result (a city with no districts → caller selects whole-city). Projected + cached. Public.
GetGeoTreeQuery (optional convenience) Query GET api/v1/geo/tree The full active province→city→district tree in one cached payload for the cascading dropdown (use if the client prefers one round-trip; otherwise the three lazy queries above suffice). Cached aggressively; invalidated on any admin geo write. Public.
CreateProvinceCommand / UpdateProvinceCommand Command POST/PUT api/v1/admin_geo/provinces[/{id}] Admin create/edit name_fa/name_en/sort_order. Admin policy. Invalidates the geo cache.
SetProvinceActiveCommand Command POST api/v1/admin_geo/provinces/{id}/set_active Toggle is_active (no delete). Cascade meaning: deactivating a province hides its cities/districts from public dropdowns (filter on the join, don't bulk-rewrite children). Invalidates cache.
CreateCityCommand / UpdateCityCommand / SetCityActiveCommand Command POST/PUT api/v1/admin_geo/cities[/{id}], …/{id}/set_active Same pattern under a province_id.
CreateDistrictCommand / UpdateDistrictCommand / SetDistrictActiveCommand Command POST/PUT api/v1/admin_geo/districts[/{id}], …/{id}/set_active Same pattern under a city_id.
AddNurseServiceAreaCommand Command POST api/v1/nurse_service_areas The signed-in nurse declares coverage: { city_id, district_id? }. district_id omitted/NULL = whole city. Validates the city/district exist and are active and that the district belongs to the city; enforces UNIQUE(nurse_id, city_id, district_id)a duplicate (including a duplicate whole-city row) returns 409, never a 500. Tenancy: nurse_id from ICurrentUser, never the body. (DEFERRED hook: this is the write that later fans out nurse_search_index rows in b7 — leave a clean extension point, do not build the index here.)
RemoveNurseServiceAreaCommand Command DELETE api/v1/nurse_service_areas/{id} Soft-removes the nurse's own area (tenancy-checked). (DEFERRED hook: triggers index row removal in b7.)
ListMyServiceAreasQuery Query GET api/v1/nurse_service_areas The nurse's own areas (city + optional district names, "whole city" flag). Tenancy-scoped, projected, paginated.
CreateAddressCommand Command POST api/v1/customer_addresses Creates an address for the signed-in customer: { title, city_id, district_id?, address_line, postal_code?, recipient_name?, recipient_phone?, is_primary? }. Encrypts address_line/postal_code/recipient fields via IFieldEncryptor. Geocodes via IGeocoder.GeocodeAsync(...) to set latitude/longitude. If is_primary=true (or it is the customer's first address), enforce single-primary (clear the prior primary in the same unit of work; the filtered unique index is the backstop). Tenancy from ICurrentUser.
UpdateAddressCommand Command PUT api/v1/customer_addresses/{id} Edit fields; re-geocode when the address line/city/district changed; re-encrypt PII. Tenancy-checked.
SetPrimaryAddressCommand Command POST api/v1/customer_addresses/{id}/set_primary Atomically makes one address primary and clears the previous (single-primary invariant).
DeleteAddressCommand Command DELETE api/v1/customer_addresses/{id} Soft-delete the customer's own address (tenancy-checked).
ListMyAddressesQuery Query GET api/v1/customer_addresses The customer's own addresses, primary first. Projected (decrypt the address for display only in the owner's own read), paginated, tenancy-scoped.
  • Controllers: GeoController (public read), AdminGeoController (admin policy; create/update/set_active), NurseServiceAreasController (nurse policy, tenancy-scoped), CustomerAddressesController (customer policy, tenancy-scoped). All sealed : BaseController, inject ISender, return base.OperationResult(...), snake_case [controller]/[action] routes, CancellationToken threaded.
  • Validators (FluentValidation): non-empty name_fa/name_en on geo create/update; valid province_id/ city_id parents; AddNurseServiceArea (city active, district-belongs-to-city when provided); CreateAddress/UpdateAddress (city active, non-empty address_line, district-belongs-to-city when provided, postal-code format if present).
  • Mapping: Mapster in the handler after the projected query (never hydrate entities to map them).

3.4 DEFERRED (build the seam/flag, not the feature)

  • nurse_search_index fan-out on service-area add/remove — DEFERRED to b7. Leave the add/remove handlers as the clean trigger point; do not build the index table or its maintenance here.
  • GPS-radius / map-tile / "nurses near me" map discoveryDEFERRED (product/business/04-search-and-matching.md §(c)). Geography is named districts, full stop. Do not add a radius/distance model.
  • Region bulk-import feed (IGeoDataImporter against an official statistics dataset) — DEFERRED; the idempotent one-time seed (§3.2) plus admin CRUD is sufficient for MVP. Note it in the report.
  • EVV distance check that consumes customer_addresses.latitude/longitudeDEFERRED to b9. This phase only produces the coordinates.

4. Mocks & seams in this phase

Seam Owner Mock behaviour Registry
IGeocoder introduced here GeocodeAsync(addressText, cityName, districtName?, ct) returns a deterministic (latitude, longitude) for the input (e.g. a stable hash-derived offset around the city centroid, or an echo of a configured static coordinate per city) plus a formatted_address and a confidence. No real network call. A config switch can force a null/low-confidence result so the "address saved without coordinates / map-pin-missing" UI states are testable. Coordinates are decimal, never float. flip reserved → 🟡
IFieldEncryptor reuse from b0 local symmetric key; encrypts address_line/postal_code/recipient fields; never logs plaintext PII; deterministic Hash available if a lookup is ever needed. reuse row
ICacheService reuse from b0/b1 in-memory; the geo-lookup queries read through GetOrCreateAsync and admin writes invalidate the geo keys. reuse row

The mock lives behind a DI-registered interface in Infrastructure (real impl is a drop-in later); selection is config-driven, never an if (mock) branch in a handler. Flip the reserved IGeocoder row in ../../shared-working-context/reports/mocks-registry.md to 🟡 with: the seam (interface + file), what's faked, the config keys it reads, and step-by-step how to make it real — register a Neshan (or Google) geocoding client implementing IGeocoder, the API-key config, the request/response mapping to (lat, lng, formatted_address, confidence), rate-limit/retry, and what to test (a known Tehran address resolves within the expected bounds).

5. Critical rules you must not get wrong

  • district_id = NULL is a MEANINGFUL value ("entire city"), not missing data. It must participate correctly in the UNIQUE(nurse_id, city_id, district_id) constraint (a nurse cannot declare the same whole-city coverage twice) and it must be a real coverage choice that later search treats as "matches every district in that city". SQL Server's default NULL-distinct behaviour will silently let duplicate whole-city rows through — defeat that with the filtered-index pair (or sentinel) in §3.1. Never treat a NULL district as "the nurse forgot to pick one".
  • Geography is named districts, NOT GPS radii. Do not implement, or leave room to implement, a radius/haversine coverage model. Coverage is the set of (city, district?) rows a nurse declared; search intersects rows, not circles. (The address lat/lng exists only for the later EVV distance check against the booking site, not for coverage matching.)
  • Respect is_active — deactivation hides, it never deletes. A toggled-off province/city/district must disappear from the public cascading dropdowns (the queries filter is_active=1 at every level and honour the parent's active state), without deleting the region or orphaning the addresses/service areas that already reference it. Deletion is reserved for genuinely erroneous rows with no children.
  • Exactly one primary address per customer. Enforce it both in the handler (clearing the prior primary in the same unit of work when a new primary is set) and with the filtered UNIQUE(customer_id) WHERE is_primary=1 index as the authoritative DB backstop — a race that tries to set a second primary fails on the constraint, not silently. The customer's first address is primary by default.
  • Addresses are encrypted PII. address_line, postal_code, and recipient name/phone go through IFieldEncryptor at rest — never stored or logged in plaintext, never returned in a list projection to anyone but the owning customer. Decrypt only in the owner's own read path. (Coordinates and the title label are not PII and may stay plaintext.)
  • Tenancy. nurse_id on a service area and customer_id on an address come from ICurrentUser, never from the request body; a nurse/customer can only read and mutate their own rows; cross-tenant access returns 404, not 403 revealing existence. (Booking-time tenancy — an address used in a booking must belong to that booking's customer — is enforced in b8/b9; don't pre-build it, but don't break it.)
  • Reference reads are cached, writes invalidate. The geo-lookup queries are read-heavy and near-static; serve them through ICacheService and invalidate the relevant keys on every admin geo write so a newly-activated city appears and a deactivated one disappears promptly. Don't hardcode the list in code.
  • Projection + pagination always. Every read uses AsNoTracking() + .Select(...) to a DTO and every list is paginated; no unbounded ToListAsync(), no entity hydration just to map.

6. Definition of Done

The shared definition-of-done.md, plus:

  • The five tables (provinces, cities, districts, nurse_service_areas, customer_addresses) exist via one migration, each with its IEntityTypeConfiguration<T>, soft-delete query filter, and audit wiring; the nurse_service_areas whole-city-aware uniqueness and the customer_addresses filtered UNIQUE(customer_id) WHERE is_primary=1 are real DB constraints; coordinates are decimal(9,6); PII columns are encrypted.
  • The seed (§3.2) loads provinces + major cities + Tehran's 22 districts idempotently and runs via the b1 seeding path.
  • All §3.3 commands/queries implemented (CQRS, OperationResult, projected + paginated + cached reads, validators), with GeoController, AdminGeoController, NurseServiceAreasController, CustomerAddressesController.
  • IGeocoder introduced (Application interface, Infrastructure mock, DI registration via a ServiceConfiguration/ extension, config-selected). No if (mock) in handlers. Address create/update sets coordinates from it.
  • Handler unit tests (NSubstitute) for: the cascading lookups, the duplicate service-area (incl. duplicate whole-city) rejection, single-primary enforcement (setting a second primary clears the first / is blocked by the filtered index), geocode wiring, and is_active filtering. ≥1 WebApplicationFactory integration test per controller (happy path, 401, validation 400). dotnet build Baya.sln zero new warnings; dotnet test Baya.sln green.
  • The Project map in server/CLAUDE.md reflects Features/Geography/**, Features/ServiceAreas/**, Features/Addresses/** and the new Geography domain folder + the IGeocoder seam; the contract dev/contracts/domains/geography-addresses.md is written and the swagger.json snapshot republished.

7. How to test (what a human can verify after this phase)

  1. Seed ran — start the API → GET api/v1/geo/provinces returns the 31 provinces ordered by sort_order; GET api/v1/geo/cities?province_id={Tehran} includes Tehran; GET api/v1/geo/districts?city_id={Tehran} returns the 22 districts; GET api/v1/geo/districts?city_id={Mashhad} returns an empty list (valid — whole-city only).
  2. Cascading dropdownprovinces → pick one → cities?province_id= → pick one → districts?city_id= each returns only is_active=1 rows in sort_order; (or GET api/v1/geo/tree returns the whole active tree in one payload).
  3. Admin togglePOST api/v1/admin_geo/cities/{id}/set_active (deactivate) → that city disappears from GET api/v1/geo/cities for its province (and its districts disappear) without being deleted; re-activate → it returns. Confirms is_active hides, not deletes.
  4. Nurse adds a service area — as a nurse, POST api/v1/nurse_service_areas { city_id } (no district) → 201, a whole-city area; GET api/v1/nurse_service_areas shows it flagged "whole city".
  5. Duplicate rejected — repeat the same POST (same city, no district) → 409 (the whole-city uniqueness fires, not a 500); add { city_id, district_id } for a district in that city → 201; repeat that → 409.
  6. Create a geocoded address — as a customer, POST api/v1/customer_addresses { title, city_id, address_line, is_primary:true }201 with latitude/longitude populated (from the IGeocoder mock); GET api/v1/customer_addresses shows it primary-first with the address decrypted for the owner.
  7. Single-primary enforced — create a second address with is_primary:true (or POST …/{id}/set_primary) → it becomes primary and the previous primary is cleared; an attempt to force two primaries is rejected by the filtered unique index. Confirm only one is_primary=1 row exists.
  8. PII not leaked — confirm address_line is stored encrypted (inspect the row / a non-owner read does not return the plaintext) and never appears in logs.

8. Hand off & document (close the phase)

  • Docs to update: the Project map in server/CLAUDE.md (add the new Geography domain + the Features/Geography|ServiceAreas|Addresses/** areas and the IGeocoder seam); if you confirm/decide a rule the product docs don't capture — e.g. the filtered-index pair chosen to make whole-city (district_id=NULL) uniqueness real, or the address-is-primary-by-default-on-first rule — record it in product/data-model/02-geography.md or 01-identity-and-access.md (regenerate the HTML view per product/CLAUDE.md). Don't invent rules.
  • Contract to write: dev/contracts/domains/geography-addresses.md (per ../../contracts/domains/_TEMPLATE.md) — the public geo lookups (provinces/cities/districts/tree), the admin geo CRUD + set_active endpoints, the nurse service-area add/remove/list, and the customer address CRUD + set_primary; the DTO shapes (ProvinceDto, CityDto, DistrictDto, NurseServiceAreaDto with the "whole city" flag, CustomerAddressDto with masked PII and decimal coordinates); the district_id=NULL ⇒ whole-city semantics spelled out; auth/rate-limit/tenancy notes; the 409 duplicate-area and single-primary side-effects. Republish the swagger.json snapshot per ../../contracts/openapi/README.md. This is what f3-b4 consumes.
  • Handoff & report: write dev/shared-working-context/backend/handoff/after-backend-phase-4.md (geo hierarchy + addresses + service areas are live; what f3 can now build — address book + map-pin picker + cascading province/city/district dropdowns, nurse coverage-area editor; which endpoints/contracts are live; that geocoding is mocked behind IGeocoder; the district_id=NULL semantics the frontend must honour). Append to backend/STATUS.md, write dev/shared-working-context/reports/backend-phase-4-report.md (what was built, what is now testable and exactly how per §7, what is mocked + how to make it real, contract produced, follow-ups: the b7 search fan-out hook, the DEFERRED EVV distance check, the region bulk-import feed), and update dev/shared-working-context/reports/mocks-registry.md (flip the IGeocoder row → 🟡).
  • Memory: save a project memory note for the non-obvious decisions this phase fixes — the whole-city (district_id=NULL) uniqueness solution (the filtered-index pair / sentinel), the named-districts-not-GPS-radii rule, the single-primary filtered unique + handler pattern, the address-PII encryption columns, and the IGeocoder seam — with a one-line pointer in MEMORY.md.