4.8 KiB
ADR-0021: A review is anchored to an order line
- Status: Accepted
- Date: 2026-08-13
Context
Product reviews are worth having only if they are worth believing. The usual shape — an open form on the product page, with a "verified purchase" badge awarded to reviews the system can match back to an order — gets this backwards. The badge becomes the exception, unverified reviews become the bulk of the content, and moderation becomes a full-time job rather than a screen someone checks.
The badge is also weaker than it looks. Matching on product + email means anyone who guesses a customer's address can post as them, and anyone who bought once can review every colourway.
Meanwhile this store has no customer accounts yet (M8). A design that depends on login would mean no reviews until then.
Decision
Review.orderLineId is unique and required. A review hangs off the exact
purchased item, not off a product plus a claim about who is writing.
This makes "verified purchase" structural. There is no unverified review
because there is no row to put one in. isVerifiedPurchase is sent to the
storefront as a constant true — not because the check is skipped, but because
the schema already made it unfalsifiable.
The unique constraint gives one review per item purchased for free. Buying the same jacket twice earns two reviews; buying it once earns one.
Authorisation is the order id plus the email on that order. The same bar the guest order lookup already sets, and the same capability URL the confirmation page uses (ADR-0019's neighbour: the id is a UUIDv7, unguessable, and holding the link is the authorisation). No account required, which is what lets reviews ship before M8.
Wrong email and unknown order return the same 404, so the endpoint cannot be used to test whether an order exists.
Everything starts PENDING. Publication requires a decision. The
alternative — publish then moderate — means the first person to read abuse on a
product page is a customer.
The rating aggregate lives on products as rating_sum + rating_count,
and is recomputed, never incremented. Two integers rather than a stored
average, because approving one more 4-star review is sum + 4, count + 1:
exact, and reversible. See Consequences.
Consequences
A shopper cannot review a product they own but bought elsewhere. That is the correct trade here: the alternative is an open submission endpoint, which is a spam surface needing a moderation team rather than a moderation screen.
Reviews work for guests today and keep working when accounts arrive — an
orderLine already reaches a customerId through its order when there is one.
Recompute-not-increment is what makes a moderator's decision reversible. Every incremental scheme needs a compensating delta per path (approve, reject, un-reject, edit), and the first missed path leaves a product displaying a rating no review supports, with nothing to notice it by. Recomputing from the approved rows is one statement that is correct from any starting state — and it is exercised: rejecting an approved review takes its stars back out.
Moderation drops the whole catalog cache. products.rating_* is part of the
read model, cache-keys.ts states the contract as "invalidated on write, TTL
as a safety net", and without this a moderator approves a review, reloads the
product page, sees the old figure for up to five minutes and concludes the
button is broken.
The distribution is sent alongside the average because they answer different questions. A 3.0 of straight 3s is a mediocre product; a 3.0 of 5s and 1s is a product with a sizing problem. Only the bars distinguish them.
Ratings are constrained BETWEEN 1 AND 5 in the database as well as in Zod.
The column feeds a stored SUM, so one bad row skews a product's average
silently and permanently.
Alternatives considered
Open reviews with a verified badge. More content, most of it unverifiable, and a badge that a determined poster can forge by guessing an email.
Require an account. Cleanest identity story, and it would have meant no reviews at all until M8 — while making guest buyers, who are most of them, second-class.
Store the average directly. One column instead of two, and it must be recomputed from scratch to stay honest anyway — at which point it is strictly worse than the pair, since it also accumulates floating-point drift.
Publish immediately, moderate later. Faster for the honest majority, and it puts the store's name under whatever the first bad actor writes.
Gate on delivery ("only review what arrived"). Correct in principle and currently unknowable: the store has no shipping integration until M10, so "delivered" is not a fact the system holds. Cancelled orders are refused; the rest are allowed, and the gate tightens when the data exists.