# Backend Phase 14 — Reviews, ratings & patient care records

> **Mission:** close the trust loop and the continuity-of-care loop. Let a customer leave **one
> moderated review per completed booking**, run that review through a moderation pipeline, and keep the
> nurse's public rating **honest** by recomputing it from source on *every* status transition — so hiding
> a 1-star never leaves an inflated average. Auto-raise an internal safety alert on low ratings. Separately,
> let nurses author **encrypted, patient-scoped clinical notes** that accumulate into a longitudinal care
> history a new nurse can read before taking over — under strict clinical access control. This is a
> brand-survival area: buyers are vulnerable people cared for unobserved at home.
>
> **Track:** backend · **Depends on:** [backend-phase-9](backend-phase-9.md) (completed bookings + dispute window), [backend-phase-3](backend-phase-3.md) (profiles/patients), [backend-phase-1](backend-phase-1.md) (`support_alerts`, `platform_configs`, `audit_logs`, notifications), [backend-phase-7](backend-phase-7.md) (search aggregates) · **Unlocks:** the reviews UI ([frontend-phase-13-b14](../frontend/frontend-phase-13-b14.md))
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.

---

## 1. Context — where this sits

This is the trust-and-continuity phase. By now bookings can reach a **completed/closed** state (b9), the
nurse and customer profiles + patients exist (b3), the platform can raise internal `support_alerts` and
read config (b1), and the search index carries a denormalized nurse rating/count that must stay current
(b7). This phase turns those pieces into the two things families actually judge a marketplace on after the
visit: **a trustworthy public rating** and **clinical continuity across nurses**.

Two distinct sub-domains live here, and they must not be conflated:

1. **Reviews & ratings** — public, social-proof, moderated, aggregate-driving.
2. **Patient care records** — private, clinical, encrypted, **patient-scoped** (not booking-scoped),
   accessed only by people with a clinical right to see them.

**What already exists (do not rebuild):**

- **Completed bookings + dispute window** — [backend-phase-9](backend-phase-9.md) built `bookings` with the
  3-amount split, the lifecycle that reaches a **completed/closed** status, `booking_sessions`,
  `booking_care_instructions` (the two-stage clinical disclosure gate), `visit_verifications` (EVV), and set
  `dispute_window_ends_at = completed_at + dispute_window_hours` on completion. Read these statuses and
  relations; do not re-model booking lifecycle.
- **Profiles & patients** — [backend-phase-3](backend-phase-3.md) built `customer_profiles`,
  `nurse_profiles` (including the denormalized **aggregate rating/count fields** you recompute here), and
  `patients` (with customer tenancy). The aggregate columns on `nurse_profiles` are *owned* by the nurse
  domain but **written by this phase** on every review transition.
- **Platform signals & config** — [backend-phase-1](backend-phase-1.md) built `support_alerts` (the
  internal-only staff worklist + `RaiseSupportAlert` API), `platform_configs` (the typed cached accessor —
  including `min_rating_for_support_alert`), `audit_logs` (append-only, written by the SaveChanges
  interceptor on sensitive entities), and the in-app `notifications` write. **Reuse all of these** — you
  *raise* alerts, you do not define the table.
- **Search aggregates** — [backend-phase-7](backend-phase-7.md) built `nurse_search_index` behind the
  `INurseSearch` seam, with **maintenance hooks** to refresh a nurse's denormalized rating/count. Every
  review transition that changes the nurse aggregate must trigger that refresh — do not write the search
  index directly; call the b7 maintenance hook.
- **Cross-cutting seams** — [backend-phase-0](backend-phase-0.md) introduced `IFieldEncryptor` (the field
  encryptor you use for clinical notes), `ICacheService`, `IDateTimeProvider`, and `INotificationDispatcher`.
  **Reuse `IFieldEncryptor`** for `patient_care_records`; do not introduce a new encryption seam.

> The **ticket/messaging** system (`tickets`, `ticket_participants`, `ticket_messages`), `partner_centers`,
> and the admin support-alert *worklist console* land in [backend-phase-15](backend-phase-15.md). This phase
> *raises* alerts and *consumes* the existing `support_alerts` raise API; it does **not** build the ticket
> system or the alert worklist UI. **(DEFERRED → b15.)**

## 2. Required reading (do this first)

- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
  [`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- **Product — business rules (source of truth):**
  [`product/business/11-reviews-trust-and-safety.md`](../../../product/business/11-reviews-trust-and-safety.md)
  — the one-per-completed-booking rule, recompute-on-every-transition, the configurable low-rating threshold,
  the "patient is not the sole information source" principle, and why this is a brand-survival area.
- **Product — data model (source of truth):**
  [`product/data-model/10-reviews-and-records.md`](../../../product/data-model/10-reviews-and-records.md) —
  `reviews` (rating 1–5 CHECK, body, moderation status + fields; 1:1 → `bookings`, N:1 →
  `customer_profiles`/`nurse_profiles`), `review_tags_master`/`review_tag_links` (N:N), and
  `patient_care_records` (nurse-authored, encrypted, **patient-scoped**, strict access). Read the **"Why"**
  notes — they encode the guards you must enforce.
- **Booking statuses you gate on** — re-read the `bookings` status enum and `dispute_window_ends_at` from
  [backend-phase-9](backend-phase-9.md)'s contract (`dev/contracts/domains/bookings.md`) so you key review
  eligibility off the *exact* completed/closed status values, not a guess.
- **Config & alerts you reuse** — [backend-phase-1](backend-phase-1.md)'s handoff and
  `dev/contracts/domains/platform-signals.md` (or equivalent) for the `RaiseSupportAlert` signature, the
  `support_alerts` shape, and the typed config accessor for `min_rating_for_support_alert`.
- **Search refresh you trigger** — [backend-phase-7](backend-phase-7.md)'s handoff for the
  `INurseSearch` maintenance hook that refreshes a nurse's aggregate rating/count in `nurse_search_index`.
- **Code to mirror (existing patterns):** an existing feature folder under
  `Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/` (request `record` + `internal sealed`
  handler + `OperationResult`), an `IEntityTypeConfiguration<T>` under
  `Persistence/Configuration/<Area>Config/`, a controller under `Baya.Web.Api/Controllers/V1/`
  (`sealed`, `BaseController`, `ISender`, `base.OperationResult(...)`), and how prior phases call
  `IFieldEncryptor` for encrypted columns (b3 IBAN, b9 care instructions).
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
  (envelope, routes, status codes) and [`money-and-types.md`](../../contracts/conventions/money-and-types.md)
  (this phase has **no money**, but follow the type/format rules for ids/enums/timestamps).

## 3. Scope — build this

A vertical slice per capability: entity + EF config + migration → command/query handler(s) → controller
endpoint → contract. Everything async with `CancellationToken`; reads are `AsNoTracking()` + `.Select()`
projection + pagination; writes go through `IUnitOfWork` with a single `CommitAsync`.

### 3.1 Entities, configs & migration

Add these tables (exact names below) as a single additive EF Core migration. One
`IEntityTypeConfiguration<T>` per entity in `Persistence/Configuration/ReviewsConfig/`.

- **`reviews`** — one review per **completed** booking.
  - Columns: `id` (BIGINT PK), `booking_id` (FK → `bookings`, **UNIQUE** — enforces 1:1),
    `customer_profile_id` (FK → `customer_profiles`), `nurse_profile_id` (FK → `nurse_profiles`),
    `rating` (TINYINT/INT, **CHECK `rating BETWEEN 1 AND 5`**), `body` (NVARCHAR, nullable free text),
    `moderation_status` (enum: `pending_moderation` | `published` | `hidden` | `rejected`; default
    `pending_moderation`), `moderation_reason` (NVARCHAR, nullable — set on hide/reject),
    `moderated_by_id` (FK → users, nullable), `moderated_at` (datetimeoffset, nullable), plus the audit
    fields stamped by the interceptor (`CreatedAt/ModifiedAt/CreatedById/ModifiedById`) and soft-delete.
  - Indexes: unique on `booking_id`; index on `(nurse_profile_id, moderation_status)` for the public
    published-only list and the recompute query; index on `moderation_status` for the moderation queue.
  - Soft-delete query filter (`!IsDeleted`).
- **`review_tags_master`** — standardized tag vocabulary.
  - Columns: `id` (BIGINT PK), `code` (e.g. `punctual`, `professional`, `clean`, `kind`; UNIQUE),
    `label_fa` (NVARCHAR), `label_en` (NVARCHAR), `is_active` (BIT), display order. **Seed** a starter
    vocabulary (at minimum: `punctual`, `professional`, `clean`, `kind`, `communicative`).
- **`review_tag_links`** — N:N join `reviews` ↔ `review_tags_master`.
  - Columns: `id` (BIGINT PK) or composite key, `review_id` (FK), `review_tag_master_id` (FK),
    **UNIQUE `(review_id, review_tag_master_id)`** (no duplicate tag on a review).
- **`patient_care_records`** — nurse-authored, **encrypted**, **patient-scoped** clinical notes.
  - Columns: `id` (BIGINT PK), `patient_id` (FK → `patients`, **the scoping key — not `booking_id`**),
    `booking_id` (FK → `bookings`, **nullable**, provenance only — which visit produced the note),
    `nurse_profile_id` (FK → `nurse_profiles`, the author), `body_encrypted` (VARBINARY/NVARCHAR holding
    the `IFieldEncryptor`-encrypted clinical note — **never plaintext**), optional encrypted structured
    fields (e.g. `vitals_encrypted`) if the digest schema carries them, `recorded_at` (datetimeoffset),
    plus audit + soft-delete.
  - Indexes: `(patient_id, recorded_at DESC)` for the longitudinal history read.

> **Aggregate columns are not yours to add.** `nurse_profiles` already carries the denormalized
> `average_rating` / `review_count` (or equivalently named) columns from b3. You **write** them on every
> transition; you do not re-create them. If they are missing, add them to the b3 entity in place and note
> it in your report (do not fork a parallel aggregate table).

### 3.2 Reviews — commands & queries

Feature folder `Baya.Application/Features/Reviews/`.

- **`SubmitReviewCommand`** (`Commands/SubmitReview/`) — customer submits `rating` (1–5) + `body` (+ optional
  tag codes) for a booking.
  - Guards (return `OperationResult.FailureResult`/`NotFoundResult`, **never throw**): the booking exists
    and is owned by the calling customer (tenancy via `ICurrentUser` → `customer_profiles`); the booking is
    in a **completed/closed** status (reject `cancelled`/`expired`/in-progress); **no review already exists**
    for that booking (1:1). Insert as `pending_moderation`. If tag codes were passed, also write
    `review_tag_links` in the same transaction (validate codes against `review_tags_master`).
  - FluentValidation: `rating` in 1..5, `body` length bound.
  - On create, if `rating <= min_rating_for_support_alert` (config, default 2), **call the b1
    `RaiseSupportAlert`** with a `low_rating` type and the `review_id`/`booking_id` linkage (see §3.4).
- **`ModerateReviewCommand`** (`Commands/ModerateReview/`) — admin/AI transition: `publish` | `hide` |
  `reject` | `unpublish`. Sets `moderation_status`, `moderation_reason` (on hide/reject), `moderated_by_id`,
  `moderated_at`. **In the same transaction**: (a) recompute the nurse aggregate from source (§3.3); (b) the
  audit interceptor writes the transition to `audit_logs`. After commit, trigger the b7 search-index refresh
  for that nurse (§3.4). Optionally notify the review author of the outcome via `INotificationDispatcher`.
  - The AI verdict (auto pre-screen) runs through `IReviewModerationService` on `SubmitReview` (§4); the
    *decision authority* is still this command — a verdict can pre-set `pending_moderation` with a flag, or
    auto-`published`/auto-`hidden` per config, but the human path must always be able to override.
- **`AttachReviewTagsCommand`** (`Commands/AttachReviewTags/`) — add/replace `review_tag_links` for a review
  the caller owns (or admin). Enforce the unique `(review_id, review_tag_master_id)`.
- **`ListReviewsForNurseQuery`** (`Queries/ListReviewsForNurse/`) — **public**, paginated, returns
  **`published` only** + the nurse aggregate (avg rating + count). `AsNoTracking()` + `.Select()` projection;
  cache the aggregate read through `ICacheService` with invalidation on transition.
- **`GetReviewModerationQueueQuery`** (`Queries/GetReviewModerationQueue/`) — **admin**, paginated, filter by
  `moderation_status` (default `pending_moderation`), sortable, includes any linked low-rating alert id.
- **`GetTagAggregatesQuery`** (`Queries/GetTagAggregates/`) — per-nurse tag rollup ("% punctual" = links for
  that tag over published reviews of that nurse). Paginated/bounded; from published reviews only.

### 3.3 Aggregate recompute (internal domain service)

- **`RecomputeNurseRating`** — an internal application service (not an endpoint), invoked by **every**
  `ModerateReviewCommand` transition *and* on `SubmitReview` only insofar as a brand-new review is
  `pending_moderation` and therefore must **not** yet count. Recompute `nurse_profiles.average_rating` and
  `review_count` **from the source** — i.e. `AVG(rating)`/`COUNT(*)` over the nurse's **currently
  `published`** reviews — never by incremental `+delta`/`-delta`. This is the fix for inflated-rating-after-
  hide drift: hiding a 1-star *lowers* the count and re-derives the average from what remains public. Do it
  inside the same transaction as the status change.

### 3.4 Patient care records — commands & queries

Feature folder `Baya.Application/Features/PatientCareRecords/`.

- **`WritePatientCareRecordCommand`** (`Commands/WritePatientCareRecord/`) — a nurse authors a note for a
  **patient** (optionally tagged with the `booking_id` that produced it). Encrypt `body` via
  `IFieldEncryptor.Encrypt(...)` before persisting to `body_encrypted`. Guard: the calling nurse must have a
  **confirmed** (or active/completed) booking for that patient — a nurse cannot write notes for a patient
  they were never assigned to.
- **`GetPatientHistoryQuery`** (`Queries/GetPatientHistory/`) — patient-scoped longitudinal history,
  paginated, ordered `recorded_at DESC`. Decrypt each `body_encrypted` via `IFieldEncryptor.Decrypt(...)`
  **only after** the access check passes. **Strict access (§5):** the owning customer (patient's
  `customer_profile`), any nurse with a **confirmed** booking for that patient, and admin — *nobody else*.

### 3.5 REST endpoints

Controllers under `Baya.Web.Api/Controllers/V1/` (`sealed`, `BaseController`, inject `ISender`,
`[controller]`/`[action]` snake_case tokens, `base.OperationResult(...)`, narrowest authorize policy, OTP/
refund-grade rate limiting not required here but keep public review-read sensibly limited):

| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `POST /v1/bookings/{booking_id}/review` | `SubmitReviewCommand` | customer (owns booking) |
| `POST /v1/reviews/{id}/tags` | `AttachReviewTagsCommand` | review owner / admin |
| `PATCH /v1/reviews/{id}/status` | `ModerateReviewCommand` | admin / moderator |
| `GET /v1/nurses/{nurse_profile_id}/reviews` | `ListReviewsForNurseQuery` | public |
| `GET /v1/nurses/{nurse_profile_id}/review-tags` | `GetTagAggregatesQuery` | public |
| `GET /v1/admin/reviews/moderation-queue` | `GetReviewModerationQueueQuery` | admin |
| `POST /v1/patients/{patient_id}/care-records` | `WritePatientCareRecordCommand` | nurse (confirmed booking) |
| `GET /v1/patients/{patient_id}/care-records` | `GetPatientHistoryQuery` | owning customer / nurse w/ confirmed booking / admin |

### 3.6 Out of scope (DEFERRED — build the seam/hook, not the feature)

- Two-way (nurse-reviews-customer) double-blind reviews with timed reveal — **(DEFERRED)**, see
  `product/business/11-reviews-trust-and-safety.md` (c).
- First-class `incidents` entity + ML fraud scoring — **(DEFERRED)**; manual suspension + `support_alerts`
  cover it now.
- The ticket system, partner centers, and the admin **support-alert worklist console** —
  **(DEFERRED → [backend-phase-15](backend-phase-15.md))**. You *raise* alerts here; b15 builds the worklist.
- `SuspendNurse` / `ResolveSupportAlert` / `FlagConcern` admin actions — **(DEFERRED → b15)** with the
  support backoffice.

## 4. Mocks & seams in this phase

| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IReviewModerationService`** (AI moderation) — **INTRODUCED here** | this phase | `Task<ModerationVerdict> ScreenAsync(string reviewText, CancellationToken)` returning a verdict (`Approve`/`Flag`/`Reject` + reason). Mock = a **keyword filter / pass-through**: clean text → `Approve` (or "needs human review" per config), banned-word hit → `Flag`. No external call. Selection by config/registration. | **add row** |
| `IFieldEncryptor` (field encryption) — **REUSE from [b0](backend-phase-0.md)** | b0 | local symmetric key; `Encrypt`/`Decrypt`. Clinical notes go through it. Do not redefine. | reuse |
| `INurseSearch` maintenance hook — **REUSE from [b7](backend-phase-7.md)** | b7 | refreshes the nurse aggregate in `nurse_search_index`. Call it after every aggregate-changing transition. | reuse |
| `support_alerts` `RaiseSupportAlert` — **REUSE from [b1](backend-phase-1.md)** | b1 | inserts an internal alert row. Call it on low ratings. | reuse |
| `INotificationDispatcher` — **REUSE from [b0](backend-phase-0.md)/[b1](backend-phase-1.md)** | b0/b1 | in-app write (no push at MVP). Optional review-outcome notice. | reuse |

Register `IReviewModerationService` (interface in `Application/Contracts/`, mock impl in Infrastructure) via
a `ServiceConfiguration/` extension — never inline in `Program.cs`. Record it in
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) with: seam, file, what's faked,
config keys (e.g. banned-word list, auto-approve toggle), how to make it real (point `ScreenAsync` at a real
text classifier/LLM endpoint without touching `ModerateReviewCommand`), status 🟡.

## 5. Critical rules you must not get wrong

- **Review eligibility — completed/closed bookings only.** Create a review **only** for a booking in the
  completed/closed status, owned by the calling customer. **Never** for `cancelled`/`expired`/in-progress
  bookings, non-existent bookings, or another customer's booking. (Anti-fraud, brand integrity.)
- **Enforce 1:1 — no duplicate reviews.** A unique constraint on `reviews.booking_id` is the authoritative
  backstop; the handler also checks first and returns a clean `OperationResult` failure, never a raw DB
  exception, on a second submit.
- **Recompute the nurse aggregate on EVERY transition, FROM SOURCE.** Publish, hide, reject, **and**
  unpublish must all recompute `nurse_profiles.average_rating`/`review_count` from the nurse's **currently
  `published`** reviews — `AVG`/`COUNT` over the source set, **not** an incremental `+delta`/`-delta`. This
  is the explicit fix for the inflated-rating-after-hide drift: hiding a 1-star lowers the count and
  re-derives the average. Do it transactionally with the status change, then refresh the b7 search index.
- **Publish gate — `pending_moderation` is NEVER public.** Reviews default to `pending_moderation` and must
  never be rendered by any public/customer-facing query. `ListReviewsForNurseQuery` returns **`published`
  only**; the aggregate counts **`published` only**. Filter at the query layer, not just the UI.
- **`patient_care_records` is PATIENT-scoped, not booking-scoped.** A new nurse taking over **must** read the
  prior history before accepting — do not silo notes per booking. The scoping key is `patient_id`;
  `booking_id` is nullable provenance only.
- **Strict clinical access + encrypted at rest.** `patient_care_records` are readable **only** by: the
  owning customer (the patient's `customer_profile`), nurses with a **confirmed** booking for that patient,
  and admin. Enforce in the authorization layer (not just the route policy). All clinical fields are
  **encrypted via `IFieldEncryptor`** — never store, log, or project plaintext clinical content; decrypt only
  after the access check passes. A nurse **without** a confirmed booking for that patient is **denied** read
  and write.
- **Low-rating → `support_alerts` must fire reliably.** It is a **safety signal**. On a review at/below
  `min_rating_for_support_alert` (config, default 2), raise the alert in the same flow; a failure to raise
  must surface (it is not a best-effort fire-and-forget that can be silently swallowed).
- **`support_alerts` are internal-only.** They must never appear in any user-facing response or join. You
  *raise* them; their worklist UI is b15.
- **Append-only audit.** Every review transition writes `audit_logs` via the interceptor — never mutate or
  delete prior audit rows. The low-rating threshold is **config-driven** (read via the b1 typed accessor),
  never hard-coded.

## 6. Definition of Done

The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:

- [ ] `reviews`, `review_tags_master`, `review_tag_links`, `patient_care_records` exist via one additive
      migration with the constraints in §3.1 (unique `booking_id`, `rating` CHECK 1–5, unique
      `(review_id, review_tag_master_id)`); `review_tags_master` is seeded.
- [ ] `SubmitReview`/`ModerateReview`/`AttachReviewTags`/`ListReviewsForNurse`/`GetReviewModerationQueue`/
      `GetTagAggregates`/`WritePatientCareRecord`/`GetPatientHistory` are implemented as CQRS features with
      validators and the §3.5 endpoints, returning the standard `OperationResult` envelope.
- [ ] Every moderation transition recomputes `nurse_profiles` **from source** and triggers the b7 search
      refresh; the recompute is covered by a test that proves hide lowers both count and average.
- [ ] Low rating raises a `support_alerts` row using the b1 API and the config threshold; verified by a test.
- [ ] `patient_care_records` are encrypted at rest via `IFieldEncryptor` and gated by the strict clinical
      access rule; a nurse without a confirmed booking is denied (tested).
- [ ] `IReviewModerationService` is introduced behind a DI seam with a keyword/pass-through mock and a
      registry row.
- [ ] `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green including this phase's tests.
- [ ] The contract `dev/contracts/domains/reviews-records.md` is written and the `swagger.json` snapshot is
      refreshed; the `server/CLAUDE.md` *Project map* notes the two new feature areas + the
      `IReviewModerationService` seam.

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

Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger or
curl. Expected results below become the "what can be tested" section of your report.

1. **Submit on a completed booking → accepted.** As the owning customer, `POST /v1/bookings/{completed_id}/review`
   with `rating: 5`. → `200`, review created with `moderation_status: pending_moderation`. It does **not**
   appear in `GET /v1/nurses/{id}/reviews` yet (publish gate).
2. **Submit on a cancelled booking → rejected.** Same call against a `cancelled`/`expired` booking → an
   `OperationResult` failure (not a 500). A second submit on the already-reviewed booking → failure (1:1).
3. **Moderate publish recomputes up.** `PATCH /v1/reviews/{id}/status` `publish` → review appears in the
   public list; `GET /v1/nurses/{id}/reviews` aggregate `review_count` increments and `average_rating`
   reflects it.
4. **Moderate hide recomputes down.** Publish a 5★ and a 1★, then `hide` the 1★ → public list drops it and
   the aggregate `average_rating` rises / `review_count` decrements (re-derived from source — not stale).
5. **Low rating raises an alert.** Submit `rating: 1` → a `support_alerts` row of the low-rating type exists
   (visible only on the admin/internal path, never in any user response).
6. **Write + read a care record with access control.** As a nurse **with a confirmed booking** for patient
   P, `POST /v1/patients/{P}/care-records` with a clinical note → `200`; the stored column is ciphertext (not
   plaintext). As the owning customer or that nurse, `GET /v1/patients/{P}/care-records` → decrypted note
   returned, newest first.
7. **Unauthorized nurse denied.** As a nurse **without** any confirmed booking for patient P, both the write
   and the read of P's care records → `403`/access-denied `OperationResult`.

## 8. Hand off & document (close the phase)

- **Docs to update (same change):** `server/CLAUDE.md` *Project map* — add the `Features/Reviews` and
  `Features/PatientCareRecords` areas, the four new tables + their config folder, and the
  `IReviewModerationService` seam (where it's registered). If you had to add the aggregate columns to
  `nurse_profiles`, note it. If you discovered/decided any business rule not already in the product docs,
  reflect it in [`product/business/11-reviews-trust-and-safety.md`](../../../product/business/11-reviews-trust-and-safety.md)
  or [`product/data-model/10-reviews-and-records.md`](../../../product/data-model/10-reviews-and-records.md)
  (no invented rules — record decisions, and regenerate the HTML view per `product/CLAUDE.md` if you touched
  Markdown).
- **Contract to write:** publish **`dev/contracts/domains/reviews-records.md`** (the §3.5 routes, request/
  response shapes, the `moderation_status` enum, the `review_tag` codes, the care-record access-rule matrix,
  status codes, examples) per [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md),
  and refresh the `swagger.json` snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md)
  so [frontend-phase-13-b14](../frontend/frontend-phase-13-b14.md) can derive its types (it does not guess shapes).
- **Handoff & report:** write `shared-working-context/backend/handoff/after-backend-phase-14.md` (reviews +
  care-records endpoints are live; what f13 can now build; what's mocked — the AI moderation seam; the
  publish-gate and clinical-access rules the frontend must respect), append your phase summary to
  `shared-working-context/backend/STATUS.md`, write `reports/backend-phase-14-report.md` (what was built,
  what is now testable and exactly how — the §7 steps — what is mocked + how to make it real, contracts
  produced, follow-ups for b15), and update
  [`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) with the
  `IReviewModerationService` row → 🟡.
- **Memory:** save a `project`-type memory note for the non-obvious decisions here — the **recompute-from-
  source (not delta)** rule and where it's invoked, the **patient-scoped (not booking-scoped)** care-record
  access matrix, and the `IReviewModerationService` seam selection — with a one-line `MEMORY.md` pointer.
