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>
This commit is contained in:
@@ -179,6 +179,40 @@ POST /api/admin/v2/marketplaces/{id}/revisions/{revId}/rollback -- creates a NEW
|
||||
|
||||
Replaces the current builder's `localStorage`-only draft persistence and the empty `apiEndpoints.builder: {}` placeholder in bootstrap. CMS/static-page content (currently in-memory bootstrap only) gets a real write path through this same revision model.
|
||||
|
||||
### 5.1 Revision immutability, stated precisely
|
||||
|
||||
Added 2026-08-21 (FH-2.7). §5 says a published revision "becomes immutable" and that rollback creates a new revision. The details that make that true:
|
||||
|
||||
- `version` is an integer, assigned as `max(version) + 1` for the marketplace, inside the publishing transaction. `UNIQUE (marketplaceId, version)`.
|
||||
- A revision row stores the **materialized** snapshot — the effective content and configuration at publish time, not references that can later resolve differently. A product renamed tomorrow does not retroactively change what was published today.
|
||||
- Publishing flips a single `publishedRevision` pointer in the same transaction that writes the snapshot. There is no window in which a marketplace is serving a half-published state.
|
||||
- Rollback reads revision *n*, writes it as revision *max+1*, and points at that. Revision *n* is untouched. History only grows.
|
||||
- **Operational state does not travel with a revision.** Inventory, reservations, orders and payments are live data. Rolling back last week's design must not roll back this week's stock. This is worth stating because it is the single most tempting shortcut in a revision system and the most expensive one to discover in production.
|
||||
|
||||
### 5.2 Clone
|
||||
|
||||
Added 2026-08-21 (FH-2.7). Launching marketplace *n+1* from an existing one is the platform's core promise, so what a clone does and does not carry is a contract, not an implementation choice.
|
||||
|
||||
Carried: theme and design configuration, sections, pages, navigation, category tree, collections, and offer assignments.
|
||||
|
||||
**Not** carried, under any flag: domains, admin users and memberships, customers, customer sessions, orders, payments, payment credentials, webhook secrets, audit history.
|
||||
|
||||
Inventory in the clone starts at zero unless a platform-scope role explicitly opts otherwise. Cloning stock by default means a new storefront can sell units that a different storefront is holding.
|
||||
|
||||
The category tree is copied by a topological walk with explicit cycle detection — a cycle is a `400` naming the offending categories, never an infinite loop and never a silently truncated tree.
|
||||
|
||||
### 5.3 Preview is signed and read-only
|
||||
|
||||
Added 2026-08-21 (FH-2.6). We have preview in the product and no preview safety anywhere in these contracts.
|
||||
|
||||
```
|
||||
POST /api/admin/v2/marketplaces/{id}/preview-token -> { url, expiresAt }
|
||||
```
|
||||
|
||||
- The token is an HMAC signature over `{ marketplaceId, expiresAt, nonce }`, TTL 15 minutes, delivered as an `HttpOnly` cookie scoped to the preview host. Signature comparison is constant-time; an invalid or expired token is `404`, not `401` — an unpublished storefront should not confirm its own existence.
|
||||
- **While a preview cookie is present, every non-`GET` on the public API returns `404`.** Enforced by a hook ahead of routing, not per endpoint. Preview exists to look at an unpublished storefront, never to transact against one — otherwise preview becomes a way to place real orders and move real stock against a design nobody approved.
|
||||
- Preview responses carry `X-Robots-Tag: noindex, nofollow`.
|
||||
|
||||
## 6. Tenant resolution hardening
|
||||
|
||||
```
|
||||
@@ -188,6 +222,16 @@ GET /api/v2/storefront/bootstrap -- resolved server-side from verified Host h
|
||||
- Host is normalized and matched against `MarketplaceDomain` server-side — the marketplace ID from the browser is never a trust boundary.
|
||||
- Unknown Host → `404`, with **no fallback to any other tenant**.
|
||||
|
||||
Added 2026-08-21 (FH-2.5), the parts that decide whether the two rules above actually hold:
|
||||
|
||||
- **Normalization is specified, not assumed:** lowercase, strip a trailing dot, strip the port, then match. `Shop.Example.COM.:443` and `shop.example.com` are one tenant. A normalization that differs between the lookup and the domain-verification write is a tenant-isolation bug.
|
||||
- **A domain row only resolves once `verifiedAt` is set** and the marketplace is in a serving state. An unverified domain is `404`, so pointing DNS at us is not by itself enough to make someone else's brand serve.
|
||||
- Host lookups may be cached briefly (~30 s) — with **explicit invalidation** on domain add, verify, remove, and marketplace state change. Without invalidation, a suspended marketplace keeps serving for the length of the cache, which is the wrong side to fail on.
|
||||
- `Host` is read from the verified proxy header chain, with the proxy configured to overwrite rather than append what the client sent. A client-supplied `Host`/`X-Forwarded-Host` is not evidence.
|
||||
- **No public endpoint accepts a `marketplaceId`** in path, query, or body. If one does, the Host check is decoration.
|
||||
|
||||
**Acceptance:** a request with an unknown or unverified Host returns 404 and no data belonging to any other tenant.
|
||||
|
||||
## 7. What the frontend will start doing once this ships
|
||||
|
||||
- Build the backoffice **Marketplaces** section (missing from admin nav today): registry, type, status, domains, currencies, feature set, responsible manager.
|
||||
|
||||
Reference in New Issue
Block a user