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>
10 KiB
Phase 3 Backend Contract — Product/Offer Split, Inventory, Executability
Companion to 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.
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
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,soldare counted separately, never derived from one another implicitly.- Reservations are created at checkout or pre-payment (tenant-configurable strategy) and expire by TTL, releasing
reservedback toavailable. - 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:
UPDATE inventory
SET reserved = reserved + :qty
WHERE offer_id = :offerId
AND (available - reserved) >= :qty
RETURNING id
- Zero rows returned means insufficient stock. Respond
409with 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
SELECTbefore theUPDATE. No advisory lock. No application-level retry loop. TheWHEREclause is the concurrency control. - Reservation TTL is 15 minutes from checkout-session creation. Expiry releases
reservedback toavailableand 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 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:
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.
resultingAvailableis 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
actoris 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
Fulfillmenttype it can realistically satisfy (see Phase 2 contractFulfillment.type). - Stock policy is
trackwithavailable > 0, orno_track/preorderexplicitly. - 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.
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:
FulfillmentModeis a property of the offer.code_pooloffers deriveavailablefrom the count ofavailablecodes — the two must not be maintained independently.valueHashis 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 → reservedunder the same conditional-write rule as §3.1, andreserved → assignedonly on confirmed payment. - A code is returned to the browser only when the order is
paid,processing, orfulfilled. 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
Itemdomain — 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.