31 KiB
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 variants — b5 built
service_categories,service_option_groups/service_option_values, and the nurse-sidenurse_service_variants(nurse_id,service_category_id,price,price_unit∈per_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'sISearchIndexWriternote); this phase owns the real implementation behind that hook — do not re-create the variant CRUD. - Geography & service areas — b4 built
provinces/cities/districts(each withsort_order,is_active) andnurse_service_areas(nurse_id,city_id,district_idNULL,UNIQUE(nurse_id, city_id, district_id);district_id = NULLmeans 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 aggregates — b3 built
nurse_profiles(user_idUNIQUE, the guardedis_verifiedBIT,is_accepting_bookingsBIT, the denormalizedaverage_rating/total_reviews/total_completed_bookings) andusers.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_verifiedflip — b6 builtnurse_verifications(the sole source of verification truth;status∈not_started/pending/in_review/approved/rejected/suspended) and the guarded flip that setsnurse_profiles.is_verified=1only inside the confirm transaction (and reverses it on suspension). This phase hooks index maintenance onto that flip so flippingis_verifiedflips the rows'is_searchable— it does not touch the verification pipeline. - The b0 foundation + b1 plumbing: REST surface,
BaseController,OperationResult<T>, CQRS viamartinothamar/Mediator,IDateTimeProvider, the typed cachedplatform_configsaccessor, and theICacheServiceseam (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.mdand../_shared/backend-conventions-checklist.md— especially Persistence (AsNoTracking +.Selectprojection, pagination on every list, oneIEntityTypeConfiguration<T>per entity) and Performance/caching (cache read-heavy data behind the cache seam).../../../product/business/04-search-and-matching.md— the 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.md— the canonicalnurse_search_indexschema (the field table:variant_id,nurse_id,service_category_id, copiedprice/price_unit,city_id/district_idone-row-per-area,nurse_gender, copiedaverage_rating/total_reviews/total_completed_bookings,is_searchable,updated_at) and the "maintained on writes tonurse_profiles,nurse_service_variants,nurse_service_areas,reviews"- "
is_searchable=1only 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'snurse_service_areasconfig + the Add/Remove-ServiceArea commands; b3'snurse_profilesconfig (gender + rating aggregate columns) and the aggregate-recompute path; b6'sis_verifiedflip transaction. Mirror theirFeatures/<Area>/{Commands|Queries}/<Name>/layout,IEntityTypeConfiguration<T>, and theIUnitOfWork/CommitAsyncpattern. - Contract conventions:
../../contracts/conventions/api-conventions.mdandmoney-and-types.md(IRRBIGINT, the envelope, pagination shape).pricein the index and in search responses is IRRlong/BIGINT— no floats. - Prior handoffs:
dev/shared-working-context/backend/handoff/after-backend-phase-5.md,…-6.md,…-4.md,…-3.md, andreports/mocks-registry.md(theINurseSearch/ICacheServiceseam 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):idBIGINT PK.variant_idBIGINT FK →nurse_service_variants.nurse_idBIGINT FK →nurse_profiles.service_category_idBIGINT FK →service_categories(copied from the variant — the primary search dimension).priceBIGINT,price_unitNVARCHAR (copied from the variant; closedprice_unitset).city_idBIGINT,district_idBIGINT NULL — the covered area this row represents.district_id = NULLis 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_genderNVARCHAR(10) — copied fromusers.gendervia the nurse, for the same-gender filter.average_rating,total_reviews,total_completed_bookings— copied fromnurse_profiles.is_searchableBIT — true only when nurseis_verified=1ANDnurse_verifications.statusnotsuspendedANDis_accepting_bookings=1AND variantis_active=1(see §5). The single visibility gate.updated_atDATETIME2 — stamped fromIDateTimeProvideron 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).
- A covering composite index for the hot search path:
- 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_atwiring 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
IUnitOfWorktransaction as its source write (singleCommitAsync) 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
ISearchIndexWritercontroller 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, IRRBIGINT) — price range over the copiedprice.- (Optional, surfaced for UI)
price_unitfilter so "per_hour" and "per_day" listings can be compared like-for-like; not required.
- Sort: by
average_ratingdescending (stable tiebreak ontotal_reviewsdesc thennurse_idso paging is deterministic). Rating sort is the only MVP sort. - Always paginated (
page/page_size, default/max per conventions) —AsNoTracking()+.Select(...)projection to aNurseSearchResultDto(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, injectISender, snake_case[controller]/[action]routes,base.OperationResult(...),CancellationTokenthreaded). PlusAdminSearchControllerfor 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 capture —
booking_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 makesnurse_gendera first-class search facet (so families can narrow up front) and stops there. (DEFERRED → b8.) - Elasticsearch backend (
ElasticNurseSearchbehindINurseSearch) 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 filter —
nurse_availability_slots/nurse_availability_exceptionsare soft guidance only; never block a search result on availability (product/business/04-search-and-matching.md§(c)). DEFERRED. - Map-based discovery / geocoding (
IGeocoderfor 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 = 1only when the nurse isis_verified = 1AND not suspended (nurse_verifications.status != 'suspended') ANDis_accepting_bookings = 1AND the variantis_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. Recomputeis_searchableon 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. TheRebuildSearchIndexCommandmust 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 = NULLmeans 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 theUNIQUE(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_genderis 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 copiedpriceand themin_price/max_pricefilters arelong/BIGINT; price-unit display +session_counttotals are not recomputed here. No float path, anywhere. - Rating-sort consistency. The copied
average_rating/total_reviews/total_completed_bookingsmust 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) callsReindexNurseCommand. - 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_indexexists via one migration with itsIEntityTypeConfiguration<T>, the covering search index, theUNIQUE(variant_id, city_id, district_id)filtered upsert guard (NULL district participating), thenurse_idsecondary index, and soft-delete/audit/updated_atwiring.- 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_searchableis recomputed correctly per §5 on each. SearchNursesQuery+GET api/v1/search/nursesimplemented behindINurseSearch(SqlNurseSearchimpl), reading onlyis_searchable=1rows, with correct NULL-district geography, the same-gender filter, price range, rating sort, projected + paginated reads, and the FluentValidation validator.INurseSearch(+ theISearchIndexWriterevent shape) introduced as Application interfaces with Infrastructure impls, DI-registered via aServiceConfiguration/extension (config-selected; noif (mock)in handlers).- Handler/unit tests (NSubstitute): the
is_searchablepredicate (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 theis_verifiedflip → indexis_searchableupdate; ≥1WebApplicationFactoryintegration test for/search/nurses(happy path, validation 400). The convergence test: incremental maintenance == full rebuild for a seeded fixture.dotnet build Baya.slnzero new warnings;dotnet test Baya.slngreen. - The
Baya.Application/Features/Search/**area + theINurseSearchseam are reflected in the Project map inserver/CLAUDE.md. - The contract
dev/contracts/domains/search.mdis written and theswagger.jsonsnapshot 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.
- Verified+accepting+active appears —
GET api/v1/search/nurses?service_category_id=…&city_id=…→ Nurse A and Nurse C appear; Nurse B does not (unverified/paused never surfaces). - 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. - Same-gender filter — add
nurse_gender=female→ only Nurse A;nurse_gender=male→ only Nurse C. - Price range — set
min_price/max_pricestraddling the variants → only the in-range variant(s) appear (IRRBIGINT, exact). - Rating sort — give Nurse A a higher
average_ratingthan Nurse C → results come back A-before-C; paging is deterministic. - 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_searchablewent 0 in the same transaction). Flip back → it reappears. - 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.
- Variant deactivate — deactivate Nurse A's variant → it stops appearing (rows
is_searchable=0), without deleting the index rows. - Rebuild convergence —
POST api/v1/admin_search/rebuild_index→ the index is identical to its incrementally-maintained state (counts andis_searchableflags match); no duplicate (variant×area) rows.
8. Hand off & document (close the phase)
- Docs to update: the Project map in
server/CLAUDE.md(add theFeatures/Search/**area, thenurse_search_indexprojection, and theINurseSearchseam + 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.mdor../../../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 publicGET api/v1/search/nursesendpoint (filters:service_category_id,city_id, optionaldistrict_id, optionalnurse_gender, optionalmin_price/max_price, pagination; rating sort; the NULL-district = whole-city matching rule documented explicitly), theNurseSearchResultDtoshape (IRRBIGINTprice+price_unitenum,nurse_gender, rating fields), the adminPOST api/v1/admin_search/rebuild_index, auth/rate-limit notes, and that onlyis_searchable=1rows are ever returned. Republish theswagger.jsonsnapshot 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 withINurseSearchallowing a later Elastic swap, and that booking-siderequired_caregiver_gendercapture lands in b8), append tobackend/STATUS.md, writedev/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 updatedev/shared-working-context/reports/mocks-registry.md(theINurseSearchrow → 🟢 SQL real, Elastic 🟡; theISearchIndexWriteroutbox row → 🟡). - Memory: save a
projectmemory note for the non-obvious decisions this phase fixes — theis_searchablefour-condition predicate, the NULL-district = whole-city matching (both directions), the (variant × area) fan-out cardinality + theUNIQUE(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 theINurseSearch(SQL-now/Elastic-later) seam — with a one-line pointer inMEMORY.md.