Files
marketplaces/docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md
sdarbinyan f9e09b1757 docs(backend): harvest platform mechanisms into the contracts (Wave 2, FH-E.1-E.4)
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>
2026-08-21 11:12:05 +04:00

10 KiB
Raw Blame History

Phase 3 Backend Contract — Product/Offer Split, Inventory, Executability

Companion to PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md Phase 3 (Sprints 3.13.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, 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:

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 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.
  • 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 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.

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.