Files
2026-06-28 21:59:59 +03:30

340 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 15 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` (15) + `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 15, 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.