Writes the 14 harvested mechanisms from FORK-ANALYSIS-2026-08-21.md into
the backend contracts. Each section is dated 2026-08-21 and tagged FH-*
so any wording traces back to why it is worded that way.
The through-line: several contracts stated correctness as behaviour
("the webhook must be idempotent"). Behaviour written as an if-statement
gets deleted by a refactor and the failure mode is a double charge. These
sections restate it as schema and mechanism.
PHASE-3 3.1 conditional-write reservation, 409 on zero rows, cart-wide
rollback, 15 min TTL
3.2 InventoryMovement append-only journal with resultingAvailable
6 bulk import idempotent by SKU, rollback while unsold
6a digital code pools, revealed only when paid
PHASE-7 5 unique constraints for payment idempotency and webhook
replay, insert-first handling, signature over raw body,
24h poll as reconciliation not primary
TRACK-S 2.1 session model - 32 bytes stored as SHA-256 only, HttpOnly,
one cookie per contour, Argon2id params, mandatory TOTP
2.2 origin allowlist ahead of routing on every cookie mutation
4.2 AES-256-GCM envelope for stored secrets, HMAC fingerprints
8a order manager as a separate contour, scoped by membership
rows rather than by configuration
PHASE-9 5.1 revision immutability, version = max+1, pointer flipped
in-transaction, operational state does not travel
5.2 clone carry / no-carry list, inventory to zero
5.3 signed read-only preview, non-GET 404s while previewing
6 host normalization, verifiedAt required, cache invalidation
PHASE-10 3a server re-runs the editor's validation, clamp-and-fallback
PHASE-2 3.1 order publicToken, snapshot completeness, never updated
FH-2.12 rejected on the merits: our marketplace lifecycle state machine
is richer than theirs, adopting it would be a downgrade. Recorded in the
TODO so it is not raised again.
Also adds BACKEND-HANDOFF.md sections 0 and 0a - nine falsifiable
invariants as a release gate, each cross-referenced to the contract that
specifies it, plus PR and release discipline. And ADR-0006 recording what
we take, what we reject, what we keep because ours is better, and the
organizational question it deliberately does not settle.
No implementation changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
226 lines
10 KiB
Markdown
226 lines
10 KiB
Markdown
# Phase 3 Backend Contract — Product/Offer Split, Inventory, Executability
|
||
|
||
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 3 (Sprints 3.1–3.3). The largest structural change in the programme — nothing about multi-seller commerce works without it.
|
||
|
||
**Status: ready to build.** No open decisions block this phase.
|
||
|
||
---
|
||
|
||
## 1. Why this exists
|
||
|
||
Today price, stock and currency hang directly off a single admin `Product` mock domain, unrelated to the live storefront `Item` domain. A product cannot have two sellers, two prices, or two stock levels. `Offer/Listing` does not exist in any form.
|
||
|
||
## 2. The two-layer split
|
||
|
||
`Product` describes the item itself (content). `Offer` describes one seller's commercial proposition against that product (price, stock, currency, status). One product, many offers.
|
||
|
||
```ts
|
||
interface Product {
|
||
id: string;
|
||
marketplaceId: string;
|
||
categoryId: string;
|
||
brand?: string;
|
||
title: string;
|
||
description: string;
|
||
attributes: Record<string, unknown>;
|
||
media: string[];
|
||
status: 'draft' | 'moderation' | 'published' | 'paused' | 'archived';
|
||
}
|
||
|
||
interface Variant {
|
||
id: string;
|
||
productId: string;
|
||
sku: string;
|
||
barcode?: string;
|
||
optionValues: Record<string, string>; // e.g. { color: 'red', size: 'M' }
|
||
dimensions?: { weight?: number; length?: number; width?: number; height?: number };
|
||
}
|
||
|
||
interface Category {
|
||
id: string;
|
||
marketplaceId: string;
|
||
parentId: string | null;
|
||
slug: string;
|
||
attributesSchema: Record<string, unknown>;
|
||
order: number;
|
||
seo: { title?: string; description?: string };
|
||
}
|
||
|
||
interface Offer {
|
||
id: string;
|
||
marketplaceId: string;
|
||
sellerId: string;
|
||
variantId: string;
|
||
sellerSku: string;
|
||
price: Money; // Money type from Phase 1 contract
|
||
stockPolicy: 'track' | 'no_track' | 'preorder';
|
||
status: 'draft' | 'moderation' | 'published' | 'paused' | 'archived';
|
||
publishedAt?: string;
|
||
executabilityChecked: boolean; // see §5
|
||
}
|
||
|
||
interface PriceHistory {
|
||
offerId: string;
|
||
price: Money;
|
||
changedBy: string; // user id or 'sync:{connectorId}'
|
||
changedAt: string;
|
||
}
|
||
```
|
||
|
||
## 3. Inventory
|
||
|
||
```ts
|
||
interface InventoryRecord {
|
||
offerId: string;
|
||
available: number;
|
||
reserved: number;
|
||
sold: number;
|
||
warehouse?: string;
|
||
source: 'manual' | 'feed_sync' | 'connector';
|
||
}
|
||
|
||
interface StockReservation {
|
||
id: string;
|
||
offerId: string;
|
||
qty: number;
|
||
reason: 'checkout' | 'pre_payment';
|
||
expiresAt: string; // TTL
|
||
released: boolean;
|
||
}
|
||
```
|
||
|
||
Invariants:
|
||
- `available`, `reserved`, `sold` are counted separately, never derived from one another implicitly.
|
||
- Reservations are created at checkout or pre-payment (tenant-configurable strategy) and expire by TTL, releasing `reserved` back to `available`.
|
||
- Seller feed stock updates are an **idempotent upsert** — a repeated webhook must not double-decrement.
|
||
- Oversell (a sale exceeding `available`) routes to a dedicated incident queue, never silently hidden or auto-corrected.
|
||
|
||
### 3.1 Reservation must be atomic — the mechanism, not just the intent
|
||
|
||
Added 2026-08-21 (FH-2.1). The invariants above say reservations exist; they do not say how two buyers racing for the last unit are separated. Specify the mechanism, because "check availability, then reserve" is a read-then-write race and will oversell under load no matter how the code above it is written.
|
||
|
||
Reserve with a single conditional write that both tests and updates:
|
||
|
||
```sql
|
||
UPDATE inventory
|
||
SET reserved = reserved + :qty
|
||
WHERE offer_id = :offerId
|
||
AND (available - reserved) >= :qty
|
||
RETURNING id
|
||
```
|
||
|
||
- **Zero rows returned means insufficient stock.** Respond `409` with the offending offer, do not retry, do not partially reserve. A multi-line cart reserves every line inside one transaction; any line returning zero rows rolls back all of them.
|
||
- No `SELECT` before the `UPDATE`. No advisory lock. No application-level retry loop. The `WHERE` clause is the concurrency control.
|
||
- Reservation TTL is 15 minutes from checkout-session creation. Expiry releases `reserved` back to `available` and writes a journal row (§3.2).
|
||
- The same rule governs release and consumption: one conditional statement, never read-modify-write.
|
||
|
||
**Acceptance:** two concurrent checkouts for the last unit produce exactly one payable order and one clean `409`. This is scenario 3 of the acceptance list in [FORK-HARVEST-TODO.md](../FORK-HARVEST-TODO.md) and is a required e2e test, not a code-review item.
|
||
|
||
### 3.2 Inventory movements are an append-only journal
|
||
|
||
Added 2026-08-21 (FH-2.8). Every change to `available`/`reserved`/`sold` writes one immutable row:
|
||
|
||
```ts
|
||
interface InventoryMovement {
|
||
id: string;
|
||
offerId: string;
|
||
deltaAvailable: number;
|
||
deltaReserved: number;
|
||
deltaSold: number;
|
||
reason: 'checkout_reservation' | 'reservation_expired' | 'reservation_released'
|
||
| 'payment_confirmed' | 'manual_adjustment' | 'feed_sync' | 'connector_sync'
|
||
| 'refund_restock' | 'oversell_correction';
|
||
referenceType?: 'reservation' | 'order' | 'import' | 'connector';
|
||
referenceId?: string;
|
||
actor?: string; // user id for manual adjustments, null for system
|
||
resultingAvailable: number; // balance after this movement, not recomputed later
|
||
occurredAt: string;
|
||
}
|
||
```
|
||
|
||
Rules:
|
||
- Rows are never updated or deleted. A correction is a new compensating row.
|
||
- `resultingAvailable` is written at the time of the movement. Replaying the journal must reproduce the current record exactly; a divergence is a defect to investigate, not a number to overwrite.
|
||
- A manual adjustment without an `actor` is rejected.
|
||
|
||
**Why this is in the contract rather than left to implementation.** Product Plan v3.1 §10.2 opens with the complaint that our numbers cannot be explained. A quantity you cannot reconstruct is a quantity you cannot defend to a bank, an inspector, or a seller disputing a payout. This journal is what turns "the stock says 3" into "the stock says 3, and here is every movement that made it 3."
|
||
|
||
## 4. Lifecycle
|
||
|
||
```
|
||
draft -> moderation -> published -> paused/archived
|
||
```
|
||
|
||
Applies independently to both `Product` and `Offer`. Wires to the already-existing (mock) Admin Moderation module — no new frontend module needed, just a real gateway behind `ADMIN_MODERATION_GATEWAY` (token already added this session).
|
||
|
||
## 5. Publish-time executability
|
||
|
||
**An offer that cannot actually be fulfilled must not be publishable.** Before allowing `status: 'published'`, the backend validates:
|
||
- The offer has a valid `Fulfillment` type it can realistically satisfy (see [Phase 2 contract](PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md) `Fulfillment.type`).
|
||
- Stock policy is `track` with `available > 0`, or `no_track`/`preorder` explicitly.
|
||
- Required attributes for the offer's category (`Category.attributesSchema`) are present.
|
||
|
||
This is the mechanism behind the plan's §3.6/§10.2 requirement: **no branch anywhere may distinguish a normal buyer from an inspector.** The only way to guarantee that is to make every published offer genuinely executable at publish time, not to special-case checkout behavior later.
|
||
|
||
## 6. Bulk import
|
||
|
||
```
|
||
POST /api/admin/v2/products/bulk-import
|
||
Content-Type: multipart/form-data (CSV) or application/json (array)
|
||
```
|
||
|
||
Response returns a **preview** of validation errors before anything is applied — required-field validation, category-attribute validation, duplicate-SKU detection — with a separate `POST .../bulk-import/{importId}/apply` to commit after review.
|
||
|
||
Added 2026-08-21 (FH-2.15): the import is idempotent by SKU/external key, so re-running the same file updates rather than duplicating. A row-level error never publishes a partial result. An applied import can be rolled back **as long as none of its products have appeared on a paid order** — after that, archive rather than delete.
|
||
|
||
## 6a. Digital fulfilment — code pools
|
||
|
||
Added 2026-08-21 (FH-2.11). We have no digital-goods story today, and it is one table.
|
||
|
||
```ts
|
||
type FulfillmentMode = 'manual' | 'code_pool';
|
||
|
||
interface DigitalCode {
|
||
id: string;
|
||
marketplaceId: string;
|
||
offerId: string;
|
||
encryptedValue: string; // see Track S §4.2 envelope
|
||
valueHash: string; // unique per (marketplaceId, offerId)
|
||
status: 'available' | 'reserved' | 'assigned' | 'revoked';
|
||
orderLineId?: string;
|
||
createdAt: string;
|
||
assignedAt?: string;
|
||
}
|
||
```
|
||
|
||
Rules:
|
||
- `FulfillmentMode` is a property of the offer. `code_pool` offers derive `available` from the count of `available` codes — the two must not be maintained independently.
|
||
- `valueHash` is unique per `(marketplaceId, offerId)`, so importing the same code twice is refused by the database rather than by a check somebody can forget.
|
||
- A code moves `available → reserved` under the same conditional-write rule as §3.1, and `reserved → assigned` only on confirmed payment.
|
||
- **A code is returned to the browser only when the order is `paid`, `processing`, or `fulfilled`.** Any earlier state returns the line with an empty code list — not a masked value, not a placeholder.
|
||
- Revocation is terminal and audited (Track S §3).
|
||
|
||
**Acceptance:** an unpaid order never yields a code, through the UI or through a direct API call with a valid session.
|
||
|
||
## 7. Endpoints
|
||
|
||
```
|
||
GET /api/admin/v2/products?marketplaceId=&status=&search=&page=&pageSize=
|
||
GET /api/admin/v2/products/{id}
|
||
POST /api/admin/v2/products
|
||
PATCH /api/admin/v2/products/{id}
|
||
GET /api/admin/v2/offers?productId=&sellerId=&status=
|
||
POST /api/admin/v2/offers
|
||
PATCH /api/admin/v2/offers/{id}
|
||
POST /api/admin/v2/offers/{id}/publish -> runs §5 executability check, 422 with details[] on failure
|
||
GET /api/admin/v2/offers/lookup?sku=&sellerSku=&externalId= -- "find any offer by internal SKU, seller SKU, product ID, or external mapping" per plan §2.1
|
||
```
|
||
|
||
Replaces `AdminProductsLocalGateway` behind `ADMIN_PRODUCTS_GATEWAY` (token already wired this session).
|
||
|
||
## 8. What the frontend will start doing once this ships
|
||
|
||
- Unify the admin mock product domain with the live storefront `Item` domain — currently two unrelated shapes.
|
||
- Multi-seller product page: same product card, multiple offers/sellers/prices — undefined behaviour today.
|
||
- Wire the Moderation module to real lifecycle transitions instead of mock data.
|