# Layer 12 — Reviews & Ratings (Design)

**Status:** Draft · 2026-05-13
**Predecessors:** Layer 8 (Admin Console & Disputes), Layer 9 (Purchase Messaging — attachment uploads), Layer 10 (Returns), Layer 11 (Seller Payouts & Financial Reporting)
**Successors:** TBD — likely search/discovery improvements (review signals available for ranking) or seller-reply-to-review (deferred from v1)

---

## Purpose

Close the buyer-side trust loop. Today buyers have no mechanism to share feedback about a seller after the transaction completes. Sellers have no public reputation. New buyers can't see whether a seller ships promptly, packages carefully, or accurately describes their listings before risking a purchase.

**Reviews & Ratings adds:**

1. **Buyer reviews tied to delivered transactions** — every review is by definition a verified purchase.
2. **Store-level aggregate (single overall + dimension breakdown)** — buyers browsing a store see the overall star rating immediately; clicking into the store detail page shows the breakdown across four dimensions: *item as described*, *shipping speed*, *communication*, *packaging*.
3. **Public report → admin moderation queue** — any logged-in user can flag a review; admins resolve with keep/hide + activity-log audit.
4. **Future search-signal substrate** — Typesense item rows will carry the seller's denormalized `average_rating` + `review_count` so a future ranking change can use them.

## The pivot from item-level to store-level reviews

Alqove items are **one-off** — no repeat SKUs, each listing is unique. Review-the-product (Amazon model) doesn't fit: there's no product to review repeatedly. The relevant signal is the **buyer's experience with this seller** for this specific transaction: was the item as described, did it ship promptly, did the seller communicate, was it packaged well.

Reviews therefore aggregate at the **Store** level, not the Item. But each review is still **tied to a specific `order_item`** so the verified-purchase invariant holds and a buyer who buys from the same seller multiple times gets multiple review opportunities (consistent sellers rewarded; one bad transaction doesn't erase a track record).

## User model

- **Buyer.** Reactive — leaves a review some hours/days after delivery. Low frequency per buyer (a handful per year). Wants the review to be quick to file, photo-friendly (show "what I received"), and editable if they were too quick.
- **Seller.** Passive consumer — reads own reviews, can't reply in v1. High cadence: a 50-orders/week shop should see ~3-10 reviews/week assuming a 20% response rate. Cares about the aggregate (badge on their listings drives buyer trust).
- **Public viewer / prospective buyer.** Reads aggregates + recent reviews to decide whether to trust a seller. Most-frequent touchpoint of the layer.
- **Admin.** Only involved on reported content. Same cadence as Layer 10 escalations — a few per week.

Implications:

- Public read paths must be fast (cached aggregate counters, paginated review lists, indexed reads).
- Write paths are bounded by buyer cadence; we can afford transactional bookkeeping (e.g., updating `Store.average_rating` on every review change).
- The single overall rating on quick views means the buyer fills in five numbers at write time (one overall + four dimensions). Validation must not be onerous — single-click stars, optional dimensions, required overall.

## Scope

### In scope

- **`Review` model** — one per `order_item`. Buyer-of-record is the order's purchaser. Cannot be transferred. Tied via `store_id` for the aggregate query path and `order_item_id` for the verified-purchase invariant.
- **Rating dimensions:**
  - `rating` (overall, 1–5, required) — drives the single number on quick views.
  - `item_as_described`, `shipping_speed`, `communication`, `packaging` (each 1–5, optional but defaulted to the overall on write if not specified — see "Default-dimension-to-overall posture" below).
- **Free-text body** — required, min 20 chars, max 2000.
- **Title** — optional, max 120 chars.
- **Photo attachments** — 0–4 per review, reusing the Layer 9 attachment upload flow.
- **Eligibility** — buyer must be the `Purchase.buyer_id` of the `Order` containing the `order_item`. `Order.delivered_at` must be non-null. No lockout window (0h — review immediately on delivery).
- **One review per `order_item`** — DB unique constraint on `reviews.order_item_id`.
- **Edit window** — buyer can update own review for 30 days from `created_at`. Edits update `edited_at` (publicly visible) but keep `created_at` immutable. Edits recompute the store aggregate.
- **Visibility states** — `visible` (default; counts toward aggregate) | `hidden` (admin-hidden; row persists but excluded from aggregate + public lists). No `pending` state — auto-publish on submit, reports surface bad ones.
- **Store-level aggregate** — `Store.average_rating` (float, nullable) + `Store.review_count` (unsigned int, default 0) denormalized counters. Recomputed transactionally on every review create / edit / hide / unhide.
- **Dimension-level aggregates** — `Store.avg_item_as_described`, `Store.avg_shipping_speed`, `Store.avg_communication`, `Store.avg_packaging` (each float, nullable). Used by the store-detail breakdown card. Recomputed alongside the overall.
- **Public read paths** — `GET /v1/stores/{store}/reviews` (paginated), `GET /v1/stores/{store}/rating-summary` (the badge data), `GET /v1/me/reviews` (buyer's own).
- **Write paths** — `POST /v1/order-items/{order_item}/reviews`, `PATCH /v1/reviews/{review}` (within edit window).
- **Report → moderation** — `POST /v1/reviews/{review}/reports` (any logged-in user) and `/admin/reviews` queue with `POST /v1/admin/review-reports/{report}/resolve` (keep | hide + resolution note).
- **Notifications:**
  - Seller — "New review on your shop" (recipient: store owner)
  - Buyer — "Your review was published" (confirmation)
  - Admin — "New review report" (via `AdminRecipients`)
  - Buyer — "Your review was hidden — reason: …" (only when admin hides)
- **Item detail page** — inline "Sold by [Store] ★ 4.8 (142 reviews)" near the seller info. Click-through to store reviews.
- **Store detail page** — top-of-page rating badge + dimension breakdown card + paginated reviews list (newest first; photos surfaced where present).
- **Seller dashboard** — `/seller/reviews` aggregate widget + chronological list of own reviews. Read-only in v1.
- **Activity log** — every admin review action (`review.admin_hidden`, `review.admin_restored`, `review.report_resolved`) writes a `spatie/activitylog` row matching the Layer 8/10/11 pattern.
- **Typesense indexing** — push `store.average_rating` + `store.review_count` (and the four dimension averages) onto item rows so future search ranking has the signal available.

### Out of scope (deferred beyond Layer 12)

- **Seller reply to reviews** — back-and-forth surface, adds moderation complexity. Defer to a successor layer if/when sellers request it.
- **Helpful / unhelpful votes on reviews** — second-order; defer.
- **Search-ranking changes that USE the new fields** — Layer 12 surfaces them; a successor layer wires them into ranking weights. Keeps the testing surface small.
- **Multi-account / Sybil prevention** — out of scope; admin tools can address ad-hoc.
- **Anonymous reviews / "drive-by" feedback** — never. Every review is verified-purchase.
- **Review templates / structured prompts** — UX polish; defer.
- **Importing reviews from other marketplaces** — out of scope.
- **Auto-translation of reviews** — out of scope.

## Data model

### `reviews` table

| column | type | notes |
|---|---|---|
| `id` | uuid | |
| `order_item_id` | foreign uuid, **unique** | enforces one review per transaction line |
| `order_id` | foreign uuid | denormalized for query path; matches `order_items.order_id` |
| `store_id` | foreign uuid | denormalized for aggregate query path |
| `reviewer_user_id` | foreign uuid | buyer |
| `rating` | tinyint 1–5 | overall; required |
| `rating_item_as_described` | tinyint 1–5 | dimension; nullable, defaults to `rating` on write if omitted |
| `rating_shipping_speed` | tinyint 1–5 | dimension; same default-to-overall posture |
| `rating_communication` | tinyint 1–5 | same |
| `rating_packaging` | tinyint 1–5 | same |
| `title` | varchar(120) nullable | |
| `body` | text | min 20, max 2000 chars; enforced at FormRequest |
| `state` | string enum: `visible` \| `hidden` | default `visible` |
| `hidden_by_admin_id` | foreign uuid nullable | |
| `hidden_at` | timestamp nullable | |
| `hide_reason` | text nullable | admin's resolution note when hiding |
| `edited_at` | timestamp nullable | first edit stamps this; further edits update it |
| `created_at` / `updated_at` | timestamps | `created_at` immutable across edits |

Indexes: `(store_id, state, created_at desc)` (primary public-read path), `(reviewer_user_id, created_at desc)` (own-reviews path), unique on `order_item_id`.

Note: photo attachments live in the existing Layer 9 attachment table; reviews polymorphically attach via the existing `Attachable` interface. No new column needed.

### `review_reports` table

| column | type | notes |
|---|---|---|
| `id` | uuid | |
| `review_id` | foreign uuid | |
| `reported_by_user_id` | foreign uuid | any logged-in user |
| `reason` | string enum: `inappropriate` \| `spam` \| `not_about_purchase` \| `personal_info` \| `other` | |
| `reason_text` | text nullable | required when `reason = 'other'` |
| `state` | string enum: `open` \| `resolved` | default `open` |
| `resolved_by_admin_id` | foreign uuid nullable | |
| `resolved_at` | timestamp nullable | |
| `action` | string enum: `keep` \| `hide` nullable | admin's decision |
| `resolution_note` | text nullable | admin's free-text on resolve |
| `created_at` / `updated_at` | timestamps | |

Index: `(state, created_at)` — the admin queue's primary read path.

### `stores` table — new columns

| column | type | notes |
|---|---|---|
| `average_rating` | float nullable | null until first review; float because mean isn't integer |
| `review_count` | unsigned int default 0 | |
| `avg_item_as_described` | float nullable | dimension breakdown |
| `avg_shipping_speed` | float nullable | |
| `avg_communication` | float nullable | |
| `avg_packaging` | float nullable | |

All six denormalized counters recomputed transactionally on every review state change (create, edit, hide, unhide).

### `NotificationCategory` enum — new case

`Reviews` (alongside existing `Returns`, `Messages`, `Orders`, `Payouts`, etc.). Buyer/seller/admin can opt out per their notification settings UI (Layer 6 infrastructure).

## Review lifecycle walkthrough

**Happy path — buyer files a review:**

1. Buyer's order is delivered (Layer 5 `OrderFulfillmentService::markDelivered`).
2. Buyer navigates to `/purchases/{order_id}`. Per delivered `order_item` that doesn't have a review yet, a "Leave a review" CTA appears.
3. Buyer clicks → modal opens with: 5-star overall picker (required), 4 dimension pickers (optional — UI defaults each to the overall value but buyer can override), title (optional), body (required, 20–2000), 0–4 photo slots.
4. Submit → `POST /v1/order-items/{order_item}/reviews` validates eligibility (buyer owns the order, delivered_at is set, no existing review).
5. `ReviewService::create` persists the Review + recomputes the Store's six denormalized counters in one DB transaction. Auto-publishes (`state = visible`).
6. Notification fires: seller gets "New review on your shop"; buyer gets a confirmation "Your review was published."
7. The store detail page now reflects the new review immediately.

**Edit within window:**

1. Buyer goes to `/me/reviews` and clicks Edit on a review where `created_at + 30d > now`.
2. Modal pre-fills with current values. Buyer changes overall + body, submits.
3. `PATCH /v1/reviews/{review}` validates the edit window and ownership.
4. Service updates the row, stamps `edited_at`, recomputes the Store aggregate (since the rating may have changed).
5. Public store page now shows "edited" timestamp next to the review.

**Edit outside window:** PATCH returns 422 `Review can no longer be edited (30-day window expired).` UI hides the Edit button when `created_at + 30d <= now`.

**Report → moderation:**

1. Any logged-in user clicks "Report" on a review (public store page).
2. Modal asks for reason (enum) + optional free-text. Submits to `POST /v1/reviews/{review}/reports`.
3. Service creates a `ReviewReport` in `state = open`. Notification to admins via `AdminRecipients`.
4. Admin opens `/admin/reviews` queue → reviews the report + the underlying review content + the buyer.
5. Admin clicks Resolve → modal with action radio (`keep` | `hide`) + required resolution note. Submits.
6. `ReviewReportService::resolve` updates the report (`resolved`), and if `action = hide`:
   - Updates the Review (`state = hidden`, `hidden_by_admin_id`, `hidden_at`, `hide_reason`)
   - Recomputes the Store aggregate (hidden reviews don't count)
   - Fires buyer notification "Your review was hidden — reason: …"
7. Activity-log row: `review.report_resolved` with causer, properties { review_id, report_id, action, resolution_note }.

**Admin restore (rare):**

If a hidden review needs to be restored, admin uses `POST /v1/admin/reviews/{review}/restore` from the store-detail admin page. State flips back to `visible`, aggregate recomputed, activity log fires `review.admin_restored`.

## Default-dimension-to-overall posture

The UI shows dimension pickers pre-set to the overall value; the buyer can move individual dimensions away from the overall. On the API side, if a dimension is omitted (null), the service writes the overall value to that dimension column on first persist. This keeps the schema clean (no null aggregates after recompute) and avoids "dimension means are inflated by missing data."

Alternative: leave nulls in the DB and compute means with `COALESCE`. Rejected — cleaner to materialize the buyer's implicit signal at write time and treat dimensions as always-present.

## Invariants & edge cases

- **One review per `order_item`** — DB unique constraint. Attempting to POST a second time returns 409.
- **Verified purchase always** — eligibility gate at write time (buyer matches purchase, delivered_at set). The constraint is also a soft enforcement: even if a row somehow slipped past, it'd have to reference a real `order_item_id`.
- **Refund interaction** — if a buyer is refunded AFTER reviewing, the review STAYS. The verified-purchase signal ("an order was placed and delivered") remains true regardless of refund. Refunds don't unwrite reviews.
- **Return interaction (Layer 10)** — if a buyer returns the item AFTER reviewing, review stays. The transaction happened; the buyer's experience is real signal. If the seller is concerned the review is now unfair, they can use the report flow (action: `keep` is the expected admin outcome — return doesn't invalidate the review).
- **Order cancellation** — order cancelled before delivery → cannot review (delivered_at gate). Order cancelled after delivery → unusual, but if it happens, eligibility still passes (delivered_at is set). Review stays.
- **Buyer deletes account** — reviews persist (de-identified — show "Buyer" or "Former buyer" instead of name). Decision: keep aggregate intact when buyer leaves.
- **Seller closes store** — reviews persist on the now-defunct store record. Aggregate doesn't recompute but isn't displayed since the store profile isn't accessible.
- **Hidden reviews and aggregate** — hidden reviews are excluded from all six denormalized counters but the row is retained for admin audit + buyer's `/me/reviews` view (with a "hidden by admin" marker).
- **Duplicate reports on the same review** — allowed; the report queue dedupes by `review_id` for the admin's view (showing report count per review). Resolving one report against a review auto-resolves any other open reports on the same review with the same action.
- **Photos in hidden reviews** — remain attached but not publicly visible.
- **Aggregate recomputation atomicity** — every create/edit/hide/unhide wraps the Review update + the Store counter update in a single `DB::transaction`. No race on concurrent reviews (small write volume; row-level locking on the Store row is sufficient).
- **Edit window vs aggregate** — editing a review's rating recomputes the aggregate. Editing only the body does not (no rating change), but we recompute unconditionally for simplicity (cheap; bounded by per-seller review count).

## Authorization & audit

- **Buyer endpoints** (`POST /order-items/{order_item}/reviews`, `PATCH /reviews/{review}`, `GET /me/reviews`): bearer-token auth + ownership check (`reviewer_user_id === request->user()->id` for PATCH; eligibility check for POST).
- **Public endpoints** (`GET /stores/{store}/reviews`, `GET /stores/{store}/rating-summary`): no auth required. These power the store-detail page and item-detail badge for both buyers and unauthenticated visitors.
- **Report endpoint** (`POST /reviews/{review}/reports`): bearer-token auth required (must be logged in to report). No role gate — buyer, seller, or admin can all report (admin reporting is rare; sellers might report obviously-fraudulent reviews against their own shop).
- **Admin endpoints** (`/admin/reviews`, `/admin/review-reports/{report}/resolve`, `/admin/reviews/{review}/restore`): admin role required. Layer 8's admin middleware reused.
- **All admin actions** write a `spatie/activitylog` row:
  - `review.report_resolved` (action + resolution_note in properties)
  - `review.admin_hidden` (when action = hide, in addition to the report_resolved row)
  - `review.admin_restored`

## API surfaces

| method | path | purpose |
|---|---|---|
| POST | `/v1/order-items/{order_item}/reviews` | buyer creates a review |
| PATCH | `/v1/reviews/{review}` | buyer edits own review (within window) |
| GET | `/v1/me/reviews` | buyer's own reviews (paginated; includes hidden ones with marker) |
| GET | `/v1/stores/{store}/reviews` | public; paginated; default newest first |
| GET | `/v1/stores/{store}/rating-summary` | public; lightweight `{average_rating, review_count, distribution, dimensions}` |
| POST | `/v1/reviews/{review}/reports` | report a review (any logged-in user) |
| GET | `/v1/admin/review-reports` | admin moderation queue; filter by state |
| POST | `/v1/admin/review-reports/{report}/resolve` | admin keeps or hides + resolution note |
| POST | `/v1/admin/reviews/{review}/restore` | admin unhides |
| GET | `/v1/seller/reviews` | seller's aggregated reviews + drill-down (own store only) |

## UI surfaces

- **Buyer purchase detail** (`/purchases/{id}`) — for each delivered `order_item` without a review, a "Leave a review" CTA. Already-reviewed items show "View your review" with edit affordance (if within window).
- **Buyer "my reviews"** (`/me/reviews`) — new page; chronological list of own reviews with edit + delete (delete = soft "withdrew my review" state? OR not allowed in v1? **Decision in plan: not allowed in v1**; edit window suffices, hidden-by-admin is the only way to remove).
- **Public store detail** (`/stores/{id}`) — already exists (Layer 7). New top-of-page badge with single overall rating + count + click-to-jump-to-reviews. New section below product grid: rating breakdown card (dimensions) + paginated reviews list with photos + edit/edited markers + report button.
- **Public item detail** (`/items/{id}`) — already exists. New inline "Sold by [Store] ★ 4.8 (142 reviews)" near seller info, clickable to store reviews.
- **Search results / listing cards** — small inline badge "★ 4.8 (142)" on each card.
- **Seller dashboard** (`/seller`) — new widget at top showing aggregate (overall + dimension means) and review count.
- **Seller reviews page** (`/seller/reviews`) — new page; chronological list of own reviews + aggregate widget. Read-only.
- **Admin reviews queue** (`/admin/reviews`) — new page; paginated open reports + resolved reports filter; click-through to a Resolve dialog (action radio + required resolution note).
- **Admin store-detail Reviews tab** — extend the existing store-detail page (Layer 11 already added a Ledger tab; Layer 12 adds a Reviews tab) with the full review list + per-review hide/restore actions.
- **Notification bell** — new icon mapping for review notifications (`star` or `quote-bubble` — pick whatever fits the existing icon set; consult Layer 11's `banknote` precedent for icon choice).

## Plan breakdown

Layer 12 splits into **three plans**. Each plan is self-contained enough to bootstrap a fresh agent context — read the plan + this spec and you have everything needed to build it.

### Plan 1 — Foundation: Schema + Buyer Write + Public Read + Aggregates

**What this plan ships:**

- `reviews` table + `Review` model + factory + 6 denormalized counter columns on `stores`.
- `ReviewWriter` service that writes reviews AND recomputes the store aggregates inside a transaction (no event listeners — writes are synchronous and atomic).
- `ReviewEligibility` service that checks: buyer-of-record + delivered + no existing review + edit-window (for PATCH).
- POST `/v1/order-items/{order_item}/reviews` endpoint (buyer creates).
- PATCH `/v1/reviews/{review}` endpoint (buyer edits within 30d).
- GET `/v1/me/reviews` (buyer's own).
- GET `/v1/stores/{store}/reviews` (public).
- GET `/v1/stores/{store}/rating-summary` (public lightweight badge data).
- Notifications: `ReviewPublishedNotification` (buyer), `ReviewReceivedNotification` (seller).
- OpenAPI + types + api-client wrappers.
- Frontend:
  - "Leave a review" CTA on `/purchases/{id}` with the review modal (5-star overall + 4 dimensions + title + body + 0-4 photos via Layer 9 attachment flow).
  - "My reviews" page (`/me/reviews`).
  - Item-detail badge "Sold by [Store] ★ 4.8 (142 reviews)".
  - Store-detail rating badge + dimension breakdown card + paginated reviews list.
  - Seller dashboard widget + `/seller/reviews` page.

**Acceptance:** a buyer can submit a review on a delivered order_item, the store's aggregate updates in the same transaction, the public store page renders the new review immediately, and the seller receives a notification.

**Test count target:** API +60, web +20.

### Plan 2 — Reports + Moderation

**What this plan ships:**

- `review_reports` table + `ReviewReport` model + factory.
- POST `/v1/reviews/{review}/reports` endpoint (any logged-in user).
- `ReviewReportService::open` and `::resolve` methods.
- Admin queue endpoint `GET /v1/admin/review-reports` (paginated, filter by state).
- Admin resolve endpoint `POST /v1/admin/review-reports/{report}/resolve` (keep | hide + resolution note).
- Admin restore endpoint `POST /v1/admin/reviews/{review}/restore`.
- When admin hides a review: state flips, store aggregate recomputes (Review's `state = hidden` excluded), buyer notified, activity log row.
- When admin keeps: report resolves but review stays visible.
- Notifications: `ReviewReportedNotification` (admin), `ReviewHiddenNotification` (buyer).
- Activity log entries: `review.report_resolved`, `review.admin_hidden`, `review.admin_restored`.
- Frontend:
  - "Report" button on each public review (visible to any logged-in user).
  - Admin queue page (`/admin/reviews`) with state filter, paginated list, "Resolve" button → modal.
  - Resolve modal (action radio + required resolution_note ≥ 10 chars).
  - Admin store-detail Reviews tab with per-review hide/restore.

**Acceptance:** any user reports a review, admin sees it in the queue, resolving with `hide` removes the review from the public store page and recomputes the aggregate; buyer receives the hide notification with the reason.

**Test count target:** API +50, web +15.

### Plan 3 — Discovery Integration + Polish

**What this plan ships:**

- Typesense index update: item rows carry `store_average_rating`, `store_review_count`, plus the four dimension averages.
- Scout sync hooks: when `Store.average_rating` changes (after a review create/edit/hide), all items in that store are queued for reindex via Scout's existing import command or a per-store reindex helper.
- Search results / listing cards: small inline "★ 4.8 (142)" badge from the new Typesense fields.
- Edit-window UI on `/me/reviews`: edit button hides after 30 days.
- Activity log row for buyer self-edit (low priority but useful for fraud investigation): `review.buyer_edited` with diff in properties.
- Photo attachment integration finalize: ensure Layer 9 attachment flow works inline in the review modal + photos render in the public store reviews list.
- Seller-side polish:
  - Dashboard widget shows aggregate + dimension breakdown (mirror the buyer-facing store-detail card).
  - `/seller/reviews` page gains photo previews + per-review report-status badge (if a review was reported, seller sees the resolution publicly).
- Admin polish:
  - Activity log filter on the existing `/admin/activity` page for review-related entries.
  - Bulk-hide endpoint for spam-storm scenarios (`POST /v1/admin/reviews/bulk-hide` accepting a list of review_ids — guardrailed to ≤ 50 at a time + required justification).

**Acceptance:** searching on the item search surface returns results with the inline store rating badge populated from Typesense; buyer's edit affordance disappears at 30d; the seller dashboard shows photos and dimension breakdown.

**Test count target:** API +40, web +20.

## Open items deferred beyond Layer 12

- **Seller reply to reviews** — Layer 13 candidate. Adds a small text reply with character limit; admin moderates same as the review itself.
- **Helpful / unhelpful votes** — Layer 13+ if it surfaces as a need.
- **Search-ranking weights using review signals** — Layer 12 surfaces the fields; a successor layer wires them into ranking.
- **Multi-account abuse detection** — admin tools area.
- **Auto-translation of reviews** — internationalization layer (not on the near roadmap).
- **Review-of-review threading / discussion** — out of scope; reports are the only secondary surface.
- **Review-based seller suspension thresholds** — admin discretion only in v1; no automated suspension.

## Notes for future agents

If you've lost context and need to re-bootstrap Layer 12 implementation:

1. Read this spec end-to-end first.
2. The three plans are at `docs/superpowers/plans/2026-05-13-layer-12-reviews-*.md` (drafted separately).
3. Each plan's "Goal" + "Architecture" + "Prerequisites" headers tell you exactly what's already shipped and what you're building on.
4. The data model section above is the source of truth — if a plan disagrees with the spec on column shape, the spec wins.
5. The default-dimension-to-overall posture in the "Default-dimension-to-overall posture" section is non-obvious; preserve it.
6. The aggregate recomputation is transactional — never rely on an event listener for the store counter update.
7. Per-store reviews aggregate at the **store** level, not the item level (one-off items don't get review aggregates). This is the core design pivot from a generic Amazon-style review model.
