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

31 KiB
Raw Blame History

Backend Phase 7 — Search & matching (nurse search index)

Mission: make verified nurses discoverable. Build the denormalized nurse_search_index — one flat row per bookable variant per covered area (fan-out) — and the write-side maintenance hooks that keep it consistent on every change to a nurse's profile, variants, service areas, verification, or reviews. Then build the single family-facing search query (filter by category, city/district with NULL-district = whole-city, gender, price; sort by rating; paginate) behind a search-service seam so an Elasticsearch backend can drop in later without touching callers. A row is searchable only when the nurse is verified, not suspended, accepting bookings, and the variant is active — an unverified or paused nurse must never surface. This is the discovery layer the whole booking funnel stands on.

Track: backend · Depends on: b5 (catalog & variants), b6 (verification → is_verified), b4 (geography & service areas), b3 (nurse profiles, gender, rating aggregates) · Unlocks: booking discovery (b8); frontend f6-b7 Before you start, read ../_shared/agent-operating-rules.md. It is not optional.


1. Context — where this sits

This is backend phase b7, the discovery layer. Catalog (b5) gave nurses priced variants — the atomic bookable unit; geography (b4) gave them service areas (which cities/districts they travel to); verification (b6) gave them the guarded is_verified flag; profiles (b3) gave them gender and the denormalized rating aggregates. All those facts live in normalized tables across four domains. A naive "find me a verified female elder-care nurse in Tehran district 3 under X rials, best-rated first" query joins nurse_profiles → nurse_service_variants → nurse_service_areas plus a rating sort across 4+ tables — slow at modest scale. This phase flattens all of it into one maintained-on-write read model so search is a single indexed, paginated table scan, and introduces the INurseSearch seam so MVP runs on SQL today and Elasticsearch can replace the backend later by config alone. No Elasticsearch at MVP — the index table is the search backend (and stays the projection/fallback even after Elastic lands).

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

  • Catalog & bookable variantsb5 built service_categories, service_option_groups/service_option_values, and the nurse-side nurse_service_variants (nurse_id, service_category_id, price, price_unitper_hour/per_session/per_half_day/ per_day/per_24h, session_count, is_active, display_name) + nurse_service_variant_options. The variant is the bookable unit — search projects variants, not nurses. b5 already stubbed a write-side hook on variant create/edit/activate/deactivate (the catalog digest's ISearchIndexWriter note); this phase owns the real implementation behind that hook — do not re-create the variant CRUD.
  • Geography & service areasb4 built provinces/cities/districts (each with sort_order, is_active) and nurse_service_areas (nurse_id, city_id, district_id NULL, UNIQUE(nurse_id, city_id, district_id); district_id = NULL means the whole city). The geo lookup queries (ListProvinces/ListCities/ListDistricts) and the nurse Add/Remove-ServiceArea commands already exist — this phase hooks the index fan-out onto the service-area writes, it does not rebuild the geo domain or the area editor.
  • Nurse profiles, gender & rating aggregatesb3 built nurse_profiles (user_id UNIQUE, the guarded is_verified BIT, is_accepting_bookings BIT, the denormalized average_rating/total_reviews/total_completed_bookings) and users.gender (male/female). The aggregate-recompute on review/booking transitions is wired in b3/b14 — this phase reads those fields into the index, it does not own the recompute math.
  • Verification & the is_verified flipb6 built nurse_verifications (the sole source of verification truth; statusnot_started/pending/in_review/approved/ rejected/suspended) and the guarded flip that sets nurse_profiles.is_verified=1 only inside the confirm transaction (and reverses it on suspension). This phase hooks index maintenance onto that flip so flipping is_verified flips the rows' is_searchable — it does not touch the verification pipeline.
  • The b0 foundation + b1 plumbing: REST surface, BaseController, OperationResult<T>, CQRS via martinothamar/Mediator, IDateTimeProvider, the typed cached platform_configs accessor, and the ICacheService seam (optional result/geo-lookup caching). Reuse all of these.

What this phase introduces: the nurse_search_index table + its EF config + migration, the index-maintenance handlers (upsert/fan-out/remove + is_searchable recomputation + a full backfill/ rebuild job), the SearchNurses query behind the new INurseSearch seam (SQL impl SqlNurseSearch now), and the public GET /search/nurses endpoint. The same-gender filter is first-class here. Booking-side capture of booking_requests.required_caregiver_gender is owned by b8 (DEFERRED here — see §3).

2. Required reading (do this first)

  • ../_shared/agent-operating-rules.md and ../_shared/backend-conventions-checklist.md — especially Persistence (AsNoTracking + .Select projection, pagination on every list, one IEntityTypeConfiguration<T> per entity) and Performance/caching (cache read-heavy data behind the cache seam).
  • ../../../product/business/04-search-and-matching.mdthe business rules: search by category + city/district + price + rating; geography driven by nurse-declared service areas (city-level row = whole city); the denormalized index exists only when the nurse is verified + not suspended + the variant active; same-gender matching as a first-class, near-hard filter surfaced before booking; MVP (this) vs DEFERRED (map discovery, hard availability filter, algorithmic ranking).
  • ../../../product/data-model/03-services-and-pricing.mdthe canonical nurse_search_index schema (the field table: variant_id, nurse_id, service_category_id, copied price/price_unit, city_id/district_id one-row-per-area, nurse_gender, copied average_rating/total_reviews/total_completed_bookings, is_searchable, updated_at) and the "maintained on writes to nurse_profiles, nurse_service_variants, nurse_service_areas, reviews"
    • "is_searchable=1 only when the source nurse/variant are bookable" invariants. Mirror these names exactly.
  • Code to mirror: b5's Features/Catalog/** (the variant create/edit/activate/deactivate commands and the write-side hook stub you'll implement); b4's nurse_service_areas config + the Add/Remove-ServiceArea commands; b3's nurse_profiles config (gender + rating aggregate columns) and the aggregate-recompute path; b6's is_verified flip transaction. Mirror their Features/<Area>/{Commands|Queries}/<Name>/ layout, IEntityTypeConfiguration<T>, and the IUnitOfWork/CommitAsync pattern.
  • Contract conventions: ../../contracts/conventions/api-conventions.md and money-and-types.md (IRR BIGINT, the envelope, pagination shape). price in the index and in search responses is IRR long/BIGINT — no floats.
  • Prior handoffs: dev/shared-working-context/backend/handoff/after-backend-phase-5.md, …-6.md, …-4.md, …-3.md, and reports/mocks-registry.md (the INurseSearch/ICacheService seam rows).

3. Scope — build this

Money (price) is IRR long / BIGINT. The search read model + its maintenance live under Baya.Application/Features/Search/{Commands|Queries}/<Name>/; the entity in Baya.Domain/Entities/Search/; one IEntityTypeConfiguration<T> in Persistence/Configuration/SearchConfig/; the INurseSearch seam in Application/Contracts/, its SQL implementation in Infrastructure; one EF migration for the table.

3.1 Entity + migration

nurse_search_index [CORE] — denormalized read model; one row per (variant × covered area) (fan-out).

  • Fields (mirror product/data-model/03-services-and-pricing.md):
    • id BIGINT PK.
    • variant_id BIGINT FK → nurse_service_variants.
    • nurse_id BIGINT FK → nurse_profiles.
    • service_category_id BIGINT FK → service_categories (copied from the variant — the primary search dimension).
    • price BIGINT, price_unit NVARCHAR (copied from the variant; closed price_unit set).
    • city_id BIGINT, district_id BIGINT NULL — the covered area this row represents. district_id = NULL is a meaningful value: "whole city". One row per area the nurse covers (fan-out); a nurse covering 3 areas with 2 active variants yields up to 6 rows.
    • nurse_gender NVARCHAR(10) — copied from users.gender via the nurse, for the same-gender filter.
    • average_rating, total_reviews, total_completed_bookings — copied from nurse_profiles.
    • is_searchable BIT — true only when nurse is_verified=1 AND nurse_verifications.status not suspended AND is_accepting_bookings=1 AND variant is_active=1 (see §5). The single visibility gate.
    • updated_at DATETIME2 — stamped from IDateTimeProvider on every upsert.
  • Indexes (this is the whole point — get them right):
    • A covering composite index for the hot search path: (is_searchable, service_category_id, city_id, district_id) INCLUDE (price, nurse_gender, average_rating, nurse_id, variant_id) — so the filtered, rating-sorted page is served from the index without key lookups.
    • A filtered unique index UNIQUE(variant_id, city_id, district_id) WHERE deleted_at IS NULL (district_id NULL participating) so a given variant×area appears exactly once — this is the upsert target and the anti-duplication guard.
    • A secondary index on nurse_id (so a nurse-scoped rebuild/remove is cheap).
  • No business writes here. This table is a read-only projection — only the maintenance handlers in §3.2 write it, and only by re-deriving from source tables. Soft-delete + audit/updated_at wiring per conventions; the table is fully re-derivable from source by the backfill job (§3.2).

3.2 Index-maintenance handlers (the write side — owned by the source's code path)

The index is maintained inline, inside the same transaction as the source write that changes a projected fact. The projection is written only by the code path that owns the source row — a variant write reindexes that variant, a profile/verification write reindexes that nurse, a service-area write fans out/removes that nurse's rows. These are small internal commands the existing b3/b4/b5/b6 handlers call (or that the SaveChanges pipeline dispatches) — not public endpoints.

Command Trigger (source write) What it does
ReindexVariantCommand(variantId) nurse_service_variants create / edit (price, category, options) / activate / deactivate (b5) Recomputes the index rows for this variant across all the nurse's service areas: upserts one row per area (copying price/unit/category, the nurse's gender + rating + searchability), and sets is_searchable per the §5 predicate. On deactivate the variant's rows go is_searchable=0 (kept, not deleted — soft-delete the rows on hard variant deletion only, which b5 forbids).
ReindexNurseCommand(nurseId) nurse_profiles change: the is_verified flip (b6), suspend/un-suspend, is_accepting_bookings toggle (b3), and the rating-aggregate recompute on review/booking transitions (b3/b14) Re-derives every row for the nurse = (each active variant) × (each service area), recomputing is_searchable, and refreshing the copied nurse_gender/average_rating/total_reviews/total_completed_bookings. Flipping is_verified from 1→0 (or suspend) sets all the nurse's rows is_searchable=0 in one go; flipping 0→1 (with accepting + active variants) makes them searchable.
FanOutServiceAreaCommand(nurseId, cityId, districtId) nurse_service_areas add (b4) Inserts one index row per active variant for the newly-covered area (respecting the UNIQUE(variant_id, city_id, district_id) upsert guard), with is_searchable per §5.
RemoveServiceAreaRowsCommand(nurseId, cityId, districtId) nurse_service_areas remove (b4) Deletes (soft-deletes) the index rows for that nurse×area across all variants. Removing an area must drop exactly those rows — don't collapse areas or you break geo filtering (§5).
RebuildSearchIndexCommand [job] manual admin trigger / first-launch / nightly reconciliation Idempotent full rebuild: truncates+repopulates (or upserts+prunes) the entire index from nurse_profiles × nurse_service_variants × nurse_service_areas, applying §5. This is the convergence/reconciliation path — the index must be re-derivable from source at any time. Batched/paginated so it scales. Admin-only endpoint POST api/v1/admin_search/rebuild_index (rate-limited, admin policy).
  • Transactionality: the reindex step runs inside the same IUnitOfWork transaction as its source write (single CommitAsync) so the projection can never diverge from the source on a successful commit; a source write that rolls back rolls back its index change too. (The seam is shaped so a later Elastic feeder can instead consume these as outbox events — see §4 — but the SQL path applies them inline today.)
  • No ISearchIndexWriter controller surface. These commands are internal; they are invoked from the owning domain's handlers, never exposed as their own REST routes (except the admin rebuild job).

3.3 The search query + seam (the read side)

INurseSearch (Application contract) — the search-service seam. SQL implementation SqlNurseSearch (Infrastructure) reads only nurse_search_index WHERE is_searchable = 1. All callers depend on the interface, never on raw SQL or an Elastic client, so the MVP→Elastic swap is config-only.

SearchNursesQuery [CORE] — the single family-facing discovery query, delegating to INurseSearch.

  • Route: GET api/v1/search/nurses (public — discovery is pre-auth; rate-limited as an unauthenticated public endpoint).
  • Filters (all optional except category + city per the product doc's "city required, district optional"):
    • service_category_id (required) — the primary dimension.
    • city_id (required), district_id (optional) — geography matching: a city search matches both the city-only rows (district_id IS NULL, "whole city") and any row for a district in that city; a district search matches that district's rows plus the whole-city rows (NULL district covers it). Get this exactly right (§5).
    • nurse_gender (optional, male/female) — the first-class same-gender filter.
    • min_price / max_price (optional, IRR BIGINT) — price range over the copied price.
    • (Optional, surfaced for UI) price_unit filter so "per_hour" and "per_day" listings can be compared like-for-like; not required.
  • Sort: by average_rating descending (stable tiebreak on total_reviews desc then nurse_id so paging is deterministic). Rating sort is the only MVP sort.
  • Always paginated (page/page_size, default/max per conventions) — AsNoTracking() + .Select(...) projection to a NurseSearchResultDto (nurse_id, variant_id, category, price + unit, nurse_gender, average_rating, total_reviews, total_completed_bookings, city/district) — never hydrate entities to map them.
  • Caching: optionally cache hot (category, city, gender) result pages behind ICacheService (reuse the b0/b1 seam) with a short TTL, invalidated on index writes for the affected city/category — or ship no-cache at MVP and add the decorator later. Geo-lookup dropdowns (provinces/cities/districts) are already cacheable via b4; don't duplicate them here.
  • Controller: SearchController (sealed : BaseController, inject ISender, snake_case [controller]/[action] routes, base.OperationResult(...), CancellationToken threaded). Plus AdminSearchController for the rebuild job (admin policy).
  • Validators: FluentValidation on SearchNursesQuery (category + city required; min_price ≤ max_price; nurse_gender ∈ {male,female} when present; page_size ≤ max).

3.4 DEFERRED (do not build; leave the seam/pointer)

  • Booking-side same-gender capturebooking_requests.required_caregiver_gender (male/female/any) and the "surface the chosen gender into the booking flow before booking" guarantee are owned by b8. This phase makes nurse_gender a first-class search facet (so families can narrow up front) and stops there. (DEFERRED → b8.)
  • Elasticsearch backend (ElasticNurseSearch behind INurseSearch) and the feeder/outbox daemon that streams source changes into Elastic. DEFERRED — the SQL index is the MVP backend and remains the projection/fallback. Build the seam, not the Elastic impl. (See §4.)
  • Availability as a hard filternurse_availability_slots/nurse_availability_exceptions are soft guidance only; never block a search result on availability (product/business/04-search-and-matching.md §(c)). DEFERRED.
  • Map-based discovery / geocoding (IGeocoder for radius search), algorithmic ranking beyond rating, "preferred nurse" continuity-of-carer suggestions. DEFERRED.

4. Mocks & seams in this phase

Seam Owner Behaviour Registry
INurseSearch introduced here The search-service seam. MVP impl SqlNurseSearch is the real backend, not a mock — keep it production-grade: it reads nurse_search_index WHERE is_searchable=1, applies the category/city/district/gender/price filters, rating sort, and pagination. The DEFERRED ElasticNurseSearch is a config-selected drop-in later; callers depend only on INurseSearch. add a new row (🟢 SQL is real; Elastic 🟡 deferred)
ISearchIndexWriter (events) introduced here (shape only) The index-maintenance seam. The SQL path applies the reindex/fan-out/remove inline today (§3.2). Shape it so the same change events can later be routed to an outbox/queue for the Elastic feeder instead of an inline upsert — record the swap path, but do not build the queue now. add a new row (🟡 outbox deferred)
ICacheService reuse from b0/b1 in-memory; optional decorator over hot search result pages + the typed config accessor. reuse row
IDateTimeProvider reuse from b0 stamps updated_at on every index upsert (deterministic in tests). reuse row

The Elastic implementation is a DI-registered drop-in behind INurseSearch (selection by config, never an if (mock) branch in a handler). Append the INurseSearch + ISearchIndexWriter rows to ../../shared-working-context/reports/mocks-registry.md (seam, file, what's real/faked, config keys, step-by-step how to make it real — the Elastic client package, the index mapping, the feeder daemon that consumes the writer's events via outbox/CDC, the config switch that points INurseSearch at ElasticNurseSearch, and the fact that the SQL index stays the fallback/reconciliation source).

5. Critical rules you must not get wrong

  • The visibility invariant is the safety rule. A row is is_searchable = 1 only when the nurse is is_verified = 1 AND not suspended (nurse_verifications.status != 'suspended') AND is_accepting_bookings = 1 AND the variant is_active = 1. An unverified, suspended, paused, or deactivated nurse/variant must never appear in results. Get this wrong and you leak unvetted or unbookable nurses to families — the single highest-stakes bug in this phase. Recompute is_searchable on every relevant source write; never trust a stale value.
  • The bookable unit is the variant, not the nurse. Search, results, and (later) booking operate on nurse_service_variants. The index is keyed per-variant-per-area; never collapse a nurse's variants into one row.
  • The index is a read-only projection — never let search mutate source. All writes re-derive from nurse_profiles / nurse_service_variants / nurse_service_areas / reviews. The RebuildSearchIndexCommand must reconstruct the entire index from source with the same result as the incremental hooks — incremental maintenance and full rebuild must converge. If they can diverge, the maintenance logic is wrong.
  • Maintain inside the source's transaction. The reindex runs in the same unit of work as the write that changed the projected fact (one CommitAsync), so a committed source change always carries its index change and a rolled-back one carries neither. The projection is written only by the code path owning the source row — don't reindex a variant from the profile handler, or vice-versa.
  • district_id = NULL means the whole city — a real coverage value, not missing data. A city search matches both city-only rows (NULL district) and every district row in that city; a district search matches that district's rows and the whole-city (NULL) rows. NULL participates correctly in the UNIQUE(variant_id, city_id, district_id) upsert guard. Reimplementing geography as GPS radii is wrong — think named districts.
  • Fan-out cardinality is exact. One index row per (variant × covered area). Adding a service area inserts one row per active variant; removing it deletes exactly those rows; deactivating a variant flips its rows to is_searchable=0. Don't collapse, dedupe-away, or orphan rows.
  • Same-gender matching is near-hard and first-class. nurse_gender is an exposed, up-front search filter — for bodily-care, a gender mismatch is culturally unacceptable. Make same-gender easy to select and the facet prominent; never silently default or drop it. (Carrying the chosen gender into the booking request is b8.)
  • Money is IRR BIGINT, no floats. The copied price and the min_price/max_price filters are long/BIGINT; price-unit display + session_count totals are not recomputed here. No float path, anywhere.
  • Rating-sort consistency. The copied average_rating/total_reviews/total_completed_bookings must track the source recompute (every review status transition, booking completion/dispute reversal, nightly reconciliation). A hidden 1★ review must lower the rating in the index, not leave it inflated — which is why the rating-recompute path (b3/b14) calls ReindexNurseCommand.
  • Seam discipline. Controllers/handlers depend on INurseSearch, never on raw SQL or an Elastic client directly, so the MVP→Elastic swap is config-only. The SQL impl is real and production-grade, not a throwaway mock.
  • Availability is soft. Availability slots/exceptions never hard-filter search results at MVP; the nurse still accepts/rejects each request.

6. Definition of Done

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

  • nurse_search_index exists via one migration with its IEntityTypeConfiguration<T>, the covering search index, the UNIQUE(variant_id, city_id, district_id) filtered upsert guard (NULL district participating), the nurse_id secondary index, and soft-delete/audit/updated_at wiring.
  • The §3.2 maintenance commands (ReindexVariantCommand, ReindexNurseCommand, FanOutServiceAreaCommand, RemoveServiceAreaRowsCommand, RebuildSearchIndexCommand) are implemented and wired into the b3/b4/b5/b6 source handlers so every relevant write maintains the index in the same transaction. is_searchable is recomputed correctly per §5 on each.
  • SearchNursesQuery + GET api/v1/search/nurses implemented behind INurseSearch (SqlNurseSearch impl), reading only is_searchable=1 rows, with correct NULL-district geography, the same-gender filter, price range, rating sort, projected + paginated reads, and the FluentValidation validator.
  • INurseSearch (+ the ISearchIndexWriter event shape) introduced as Application interfaces with Infrastructure impls, DI-registered via a ServiceConfiguration/ extension (config-selected; no if (mock) in handlers).
  • Handler/unit tests (NSubstitute): the is_searchable predicate (verified+accepting+not-suspended+ active true; each missing condition false), the geography NULL-district matching, the gender/price filters, the rating sort, the fan-out on area add/remove, and the is_verified flip → index is_searchable update; ≥1 WebApplicationFactory integration test for /search/nurses (happy path, validation 400). The convergence test: incremental maintenance == full rebuild for a seeded fixture. dotnet build Baya.sln zero new warnings; dotnet test Baya.sln green.
  • The Baya.Application/Features/Search/** area + the INurseSearch seam are reflected in the Project map in server/CLAUDE.md.
  • The contract dev/contracts/domains/search.md is written and the swagger.json snapshot republished.

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

Seed (or reuse from prior phases) a small fixture: a province/city with ≥2 districts; Nurse A = verified (is_verified=1, nurse_verifications.status=approved), is_accepting_bookings=1, female, with one active elder-care variant priced in IRR and a service area = (city, district 3); Nurse B = not verified (or paused) with an otherwise identical variant + area; Nurse C = verified, accepting, male, active variant, service area = whole city (district_id=NULL). Give the nurses different average_ratings.

  1. Verified+accepting+active appearsGET api/v1/search/nurses?service_category_id=…&city_id=…Nurse A and Nurse C appear; Nurse B does not (unverified/paused never surfaces).
  2. Whole-city vs district geography — search with district_id= district 3 → Nurse A (district 3) and Nurse C (whole-city NULL row) both match; search the same city with no district → both still match; search a different district in that city → only Nurse C (the whole-city row) matches.
  3. Same-gender filter — add nurse_gender=female → only Nurse A; nurse_gender=male → only Nurse C.
  4. Price range — set min_price/max_price straddling the variants → only the in-range variant(s) appear (IRR BIGINT, exact).
  5. Rating sort — give Nurse A a higher average_rating than Nurse C → results come back A-before-C; paging is deterministic.
  6. Flip verification updates searchability — flip Nurse A to suspended/is_verified=0 (via the b6 path) → re-run the search → Nurse A disappears (its rows' is_searchable went 0 in the same transaction). Flip back → it reappears.
  7. Service-area fan-out — add a second service area to Nurse C → new index rows appear and Nurse C now matches that area too; remove the area → those rows (and only those) drop out.
  8. Variant deactivate — deactivate Nurse A's variant → it stops appearing (rows is_searchable=0), without deleting the index rows.
  9. Rebuild convergencePOST api/v1/admin_search/rebuild_index → the index is identical to its incrementally-maintained state (counts and is_searchable flags match); no duplicate (variant×area) rows.

8. Hand off & document (close the phase)

  • Docs to update: the Project map in server/CLAUDE.md (add the Features/Search/** area, the nurse_search_index projection, and the INurseSearch seam + where it's registered). If you discover/confirm a rule the product docs don't capture (e.g. the exact city↔district NULL-matching semantics in the query, or the incremental-vs-rebuild convergence guarantee), record it in ../../../product/business/04-search-and-matching.md or ../../../product/data-model/03-services-and-pricing.md — don't invent rules.
  • Contract to write: dev/contracts/domains/search.md (per ../../contracts/domains/_TEMPLATE.md) — the public GET api/v1/search/nurses endpoint (filters: service_category_id, city_id, optional district_id, optional nurse_gender, optional min_price/max_price, pagination; rating sort; the NULL-district = whole-city matching rule documented explicitly), the NurseSearchResultDto shape (IRR BIGINT price + price_unit enum, nurse_gender, rating fields), the admin POST api/v1/admin_search/rebuild_index, auth/rate-limit notes, and that only is_searchable=1 rows are ever returned. Republish the swagger.json snapshot per ../../contracts/openapi/README.md. This is what f6-b7 consumes.
  • Handoff & report: write dev/shared-working-context/backend/handoff/after-backend-phase-7.md (search is live, what f6 can now build — search + filters (C1), results (C2), nurse profile (C3) — which endpoint/contract is live, that the backend is the SQL index with INurseSearch allowing a later Elastic swap, and that booking-side required_caregiver_gender capture lands in b8), append to backend/STATUS.md, write dev/shared-working-context/reports/backend-phase-7-report.md (what was built, what is now testable and exactly how per §7, what is deferred + how to make it real — Elastic + feeder, contracts produced, follow-ups: the booking-side gender capture, the optional result cache, the Elastic backend), and update dev/shared-working-context/reports/mocks-registry.md (the INurseSearch row → 🟢 SQL real, Elastic 🟡; the ISearchIndexWriter outbox row → 🟡).
  • Memory: save a project memory note for the non-obvious decisions this phase fixes — the is_searchable four-condition predicate, the NULL-district = whole-city matching (both directions), the (variant × area) fan-out cardinality + the UNIQUE(variant_id, city_id, district_id) upsert guard, the "maintain-in-the-source-transaction, projection owned by the source's code path" rule, the incremental↔rebuild convergence requirement, and the INurseSearch (SQL-now/Elastic-later) seam — with a one-line pointer in MEMORY.md.