Structured dispute resolution for buyers, with deterministic auto-classification for clear-cut cases and human admin review for ambiguous ones. Disputes sit next to the returns flow, not on top of it: a return is the buyer's first recourse; a dispute is what happens when the return path doesn't resolve cleanly (rejected, fraud claims, not-as-described disputes).
State machine
filed → triaged ──┬──► auto_resolved_buyer
├──► auto_resolved_seller
└──► admin_review ──┬──► resolved_buyer
├──► resolved_seller
└──► resolved_split
filed → withdrawn (buyer-only)
filed → expired (90 days, hard cap)
Triage runs at file-time. The deterministic classifier either
auto-resolves or routes to admin_review. There's no
"buyer waits for seller response" — the seller has 72h to respond
but a missing response doesn't gate buyer-side outcomes.
Auto-resolution rules
A pure function, deterministic and exhaustively tested.
| Rule | Effect | Required |
|---|---|---|
buyer_defective_within_window_with_evidence |
Auto-resolve buyer; full refund | reason=defective AND days-since-delivery ≤ 30 AND ≥ 1 image-or-document evidence |
seller_buyer_no_evidence_after_sla |
Auto-resolve seller (dismiss) | reason=not_as_described AND seller responded within 72h AND zero buyer evidence |
buyer_not_received_after_sla_no_tracking |
Auto-resolve buyer; full refund | reason=not_received AND ((never shipped AND order placed > 7 days ago — the dispatch grace) OR (shipped > 21 days ago AND no carrier delivered event)) |
buyer_service_not_delivered |
Auto-resolve buyer; full refund | reason=service_not_delivered AND zero deliverables AND the seller's delivery commitment (paid_at + quoted_eta_minutes) has passed |
manual_admin |
Routes to admin queue | Anything ambiguous; fraud / duplicate-charge always; high-value (> £500) always; service_quality / service_scope_mismatch always (subjective) |
Defective auto-resolution pays the full order total, matching the
defective reason. Admin can split.
Schema
disputes:
| Column | Notes |
|---|---|
order_id |
The disputed goods order. Nullable — exactly one of order_id / service_quote_id is set (check constraint). ≤ 3 active disputes per target. |
service_quote_id |
The disputed service quote thread. Filing flips the quote to disputed and pauses its payout auto-release. |
return_id |
Optional FK; populated when the dispute follows a return. |
principal_id, agent_instance_id, delegation_id |
Buyer-side actor. |
seller_id |
Denormalised. |
reason |
Goods: not_as_described / defective / not_received. Services: service_not_delivered / service_quality / service_scope_mismatch. Either: fraud_unauthorized_charge / duplicate_charge / other. Reason family must match the target — enforced on FileDisputeInput. |
summary |
≤ 4 000 chars buyer free-text. |
currency |
Snapshot of the parent order/quote charge currency (gbp | usd | eur); claimed_amount_pence and resolution_pence are minor units of it. |
claimed_amount_pence |
Capped at order total. |
status, resolution_rule, resolution_pence |
|
seller_response_text / _at / _sla_at |
72h SLA. |
admin_resolved_by_principal_id, admin_override_reason |
Admin trail. |
internal_summary, internal_summary_model_version |
AI summary for admin queue (NOT exposed to buyer/seller). |
filed_at, triaged_at, resolved_at, withdrawn_at, expired_at |
Lifecycle. |
dispute_evidence:
| Column | Notes |
|---|---|
dispute_id |
FK with cascade. |
uploaded_by_principal_id |
Buyer or seller-owner only. |
kind |
image / document / delivery_proof / communication_log / other. |
mime_type, byte_size, sha256, s3_key |
25 MB per item, 200 MB per dispute. SHA-256 server-computed; immutable. |
caption |
≤ 500 chars. |
Size is bounded twice at the boundary. The route rejects on
Content-Length before the body is buffered (413 payload_too_large),
and body_b64 carries a Zod .max() of MAX_BASE64_CHARS_PER_ITEM —
the base64 encoding of the 25 MB per-item cap, derived in
evidence-store.ts
rather than restated — so an oversized upload is rejected as
evidence_too_large before it is decoded into a Buffer.
Evidence bytes are not persisted or downloadable. The evidence store
enforces the size caps and computes the SHA-256, but stores nothing —
there is no S3
upload and no retrieval endpoint. Resolution (auto and admin) relies on
the recorded metadata only (sha256, byte_size, mime_type,
caption). Setting S3_BUCKET_DISPUTE_EVIDENCE does not enable
persistence; it only makes every put log a loud error that the bytes
were dropped.
REST surface
POST /api/v1/orders/{id}/disputes # buyer files
GET /api/v1/disputes # principal-scoped
GET /api/v1/disputes/{id} # buyer or seller-owner
POST /api/v1/disputes/{id}/evidence # base64 body, multipart-style
POST /api/v1/disputes/{id}/seller-response # gated by `respond_dispute`
POST /api/v1/disputes/{id}/withdraw # buyer-only
POST /api/v1/admin/disputes/{id}/resolve # outcome=buyer/seller/split + override_reason
POST /api/v1/admin/disputes/{id}/reopen # admin reopens any closed dispute
GET /api/v1/admin/disputes # admin queue
Every write requires Idempotency-Key. Audit row on every write.
MCP surface
| Tool | R/W |
|---|---|
disputes_file |
write |
disputes_list |
read |
disputes_get |
read |
disputes_add_evidence |
write |
disputes_withdraw |
write (buyer) |
disputes_respond |
write (seller, gated by respond_dispute) |
Admin tools are REST-only (admin uses cookie session; platform actors don't get MCP).
Refund routing on resolution
When a dispute resolves buyer (auto or admin):
- Compute the reversal with
proportionalFeeReversalover the fee Stripe actually charged —chargedApplicationFeePence, i.e. commission plus any retained deemed-supply VAT — pro-rated byresolution_pence / order_total, round-half-up. Service-quote disputes pro-rate over the quoted price instead (services never take the deemed-supplier treatment). - Call
createRefundwithreverseTransfer: true. Stripe idempotency key derived from dispute id. The reversal amount is pinned against the charge's ApplicationFee rather than left to Stripe'srefund_application_feeboolean — see payments.md. - Insert a
refundsrow (return_idis nullable so dispute-only refunds don't need a synthetic return), recording the reversalcreateRefundreports Stripe executed, not the figure requested. - If the order was paid via a still-valid delegation envelope, credit the envelope; otherwise refund returns to the card per Stripe.
When seller wins, no refund. When split, the admin supplies
resolution_pence and the same flow runs against that subtotal.
Steps 1–4 are shared with the returns rail and with admin force refund.
Admin force refund
Support can refund an order outside both rails — a legacy order, a goodwill credit, or rolling back a payment-method abuse case. From where you sit it behaves like any other refund: the seller transfer reverses, the platform fee reverses in proportion, and the money returns to whatever funded the order (your delegation envelope if it is still live, otherwise the original card). A Shopify-sourced order has the refund mirrored into the seller's Shopify account so their books match.
Force refunds are capped at the order total minus everything already refunded against it, including refunds still settling at Stripe, so overlapping refunds on one order can never exceed the original charge.
Released-payout guard (service quotes)
A service quote's payout can release to the seller (worker auto-release,
buyer acknowledgement, or admin override) independently of the dispute
pipeline. To stop a dispute refund from double-spending an
already-released payout, the buyer-resolution path locks the quote row
FOR UPDATE before touching Stripe and refuses to refund when the quote
is already released, refunded, or cancelled — the dispute routes to
admin_review instead, and no refunds row is written. The terminal
quote update is status-guarded on the value read under the lock, so a
status change that races the resolution also lands in admin_review
rather than reporting a phantom success.
Filing reciprocates: a dispute filed against a quote that can no longer
be paused (released / refunded / cancelled) skips triage and goes
straight to admin_review (reason quote_not_pausable) — the pause flip
is row-count-checked, so a quote released in the file→pause window is
never auto-resolved into a refund of money that already reached the
seller. (An already-disputed quote is still safely paused and triages
normally.)
Internal AI summarisation
An internal summariser runs against the internal Anthropic key
(getAnthropicClient({ purpose: 'internal_classifier' })):
- Cap: 800 output tokens.
- Persisted into
disputes.internal_summary+internal_summary_model_version. - Same
PHASE_F_AI_BUDGET_PENCEcap as anomaly explanations (shared budget). - Visibility: the summary is shown to admins reviewing the queue. Buyers and sellers do not see this string in any UI; the API response field exists for completeness but is not exposed in customer-facing surfaces.
SLA + expiry
- Seller-response SLA: 72 hours from
filed_at. After that, the dispute remains open but auto-resolution rules treatsellerResponded=falseas the input. - Hard expiry: 90 days from
filed_at.expireOldDisputesworker flips toexpired, notifies the buyer. - Evidence retention:
- Active dispute: unbounded.
- Resolved buyer-win or seller-win: 7 years (UK consumer-rights / chargeback evidence horizon). Counsel review remains the production gate.
- Withdrawn or expired: 30-day purge.
Privacy on the admin queue
Admin reviewing a dispute sees:
- Buyer principal id, seller id, platform id.
- Order line items:
product_id, title, quantity. - All evidence metadata + captions (the file bytes themselves are not retrievable — see the evidence-store note above).
internal_summary(AI brief) + the buyer's free-text summary.- Seller response text.
- Admin resolution amount entry.
Admin does not see (in the default queue view):
- Buyer email address, name, postal address.
An audit-trailed unredact action, gated behind a separate
admin.unredact_dispute_pii permission for fraud forensics, is not
yet implemented.
Uniform returns policy (platform-wide, not seller-configurable)
Returns are uniform across every listing — sellers no longer
configure a per-listing return policy. The constants live in
packages/core/src/server/return-policy/validate.ts; the flow lives in
packages/core/src/server/returns/service.ts.
- 30-day change-of-mind window from delivery, FREE. Full refund, and
there is no restocking fee, ever (
RESTOCKING_FEE_BPS = 0;MAX_RESTOCKING_FEE_BPSwas deleted). In-window returns auto-approve. - Return shipping is seller-funded. After a return is approved the
seller must upload a prepaid label within 2 business days
(
labelDeadlineAt = approvedAt + 2 business days). The 21-day return-shipping clock starts at LABEL ISSUANCE (labelIssuedAt), not approval. - Missed-label auto-refund. If the seller misses the label deadline
the buyer is auto-refunded, recovered from the seller's balance
(
reverseTransfer: true);labelMissedAtis stamped. The buyer is then under no obligation to return unless the seller arranges collection at its own cost within 14 days (a manual op). - Faulty items (CRA 2015) apply on top and run longer. Within the
30-day short-term-reject window a faulty return auto-refunds; past 30
days (up to the 6-year limitation) the claim is recorded and routed
(status
requested) for manual repair/replacement / price-reduction / final-rejection handling — never denied. - Statutory cooling-off floor keys to the BUYER's country OR the
seller's (
statutoryCoolingOffApplies→ true when either is GB), and is snapshotted onto the return asstatutory_cooling_off. (Bug fix: it previously keyed to the seller alone, wrongly denying a GB buyer buying from a US seller.) The platform's 30-day change-of-mind guarantee is uniform regardless of country.
Dispute auto-resolution rules are country-agnostic — defective and
not_received outcomes rest on the platform guarantee, not on UK
statute. The high-value manual-review threshold (50 000 minor units)
applies at face value in every currency.
Feature flags
AGENT_DISPUTE_AUTO_RESOLVE_ENABLED— whenfalse, every dispute routes toadmin_review(the auto-classifier still runs but its verdict is ignored). Useful for the first weeks of operation.PHASE_F_AI_BUDGET_PENCE— daily AI spend cap shared with the anomaly explanation worker; default £50/day.AGENT_AI_INTERNAL_DISABLED— short-circuits internal AI for tests + emergencies.