bitroadbitroad
Using the marketplace

Disputes

Lifecycle from filing to resolution, evidence, auto-resolution rules.

Buyers & sellers

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):

  1. Compute the reversal with proportionalFeeReversal over the fee Stripe actually charged — chargedApplicationFeePence, i.e. commission plus any retained deemed-supply VAT — pro-rated by resolution_pence / order_total, round-half-up. Service-quote disputes pro-rate over the quoted price instead (services never take the deemed-supplier treatment).
  2. Call createRefund with reverseTransfer: true. Stripe idempotency key derived from dispute id. The reversal amount is pinned against the charge's ApplicationFee rather than left to Stripe's refund_application_fee boolean — see payments.md.
  3. Insert a refunds row (return_id is nullable so dispute-only refunds don't need a synthetic return), recording the reversal createRefund reports Stripe executed, not the figure requested.
  4. 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_PENCE cap 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 treat sellerResponded=false as the input.
  • Hard expiry: 90 days from filed_at. expireOldDisputes worker flips to expired, 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_BPS was 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); labelMissedAt is 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 as statutory_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 — when false, every dispute routes to admin_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.