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>
12 KiB
Phase 9 Backend Contract — Tenant Registry, Domain Automation, Publish Model
Companion to PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md Phase 9 (Sprints 9.1–9.3). Covers plan §4.3, §8.
Status: ready to build. Zero hostinger references exist in the codebase today.
1. Entities
Added 2026-08-18: two levels now sit above Marketplace, introduced by PARTNER-PROVISIONING-API-CONTRACT.md §10.
interface Company {
id: string;
name: string;
externalReference?: string; // partner's own id, when provisioned via the partner API
status: 'active' | 'suspended' | 'disabled';
createdAt: string;
updatedAt: string;
}
interface Project {
id: string;
companyId: string;
name: string; // a product line, e.g. "marketplaces"
externalReference?: string;
status: 'active' | 'suspended' | 'disabled';
createdAt: string;
updatedAt: string;
}
Both are deliberately thin — they exist to scope ownership, credentials, and payment routing, not to hold configuration. All marketplace configuration stays on Marketplace below.
A Marketplace is the partner hierarchy's store level. One project holds many marketplaces; one marketplace holds many sellers (Phase 5), and sellers are not part of that hierarchy.
interface Marketplace {
id: string;
companyId: string; // added 2026-08-18
projectId: string; // added 2026-08-18
externalReference?: string; // added 2026-08-18, partner's own id for this store
name: string;
code: string;
type: 'commerce' | 'mall_directory' | 'hybrid' | 'single_brand';
ownerId: string;
countries: string[];
locales: string[];
currencies: string[];
timezone: string;
lifecycleState: MarketplaceLifecycleState;
}
type MarketplaceLifecycleState =
| 'draft' | 'configured' | 'content_ready' | 'domains_planned'
| 'staging_live' | 'qa_passed' | 'production_ready' | 'live' | 'paused' | 'archived';
interface MarketplaceDomain {
marketplaceId: string;
domain: string;
type: 'production' | 'www' | 'staging' | 'preview' | 'api' | 'seller';
status: 'planned' | 'dns_pending' | 'ssl_pending' | 'active' | 'failed';
}
interface MarketplaceFeatureSet {
marketplaceId: string;
features: Record<string, boolean>; // e.g. { catalog: true, sellers: true, cart: true, checkout: true, payments: true, orders: true, refunds: true, directory: false, ... }
}
interface MarketplaceRevision {
id: string;
marketplaceId: string;
status: 'draft' | 'validated' | 'preview' | 'published';
publishedAt?: string;
supersedesRevisionId?: string; // rollback creates a NEW revision, never mutates the old one
}
Hard invariant: Order, Payment, InventoryRecord, and every financial ledger row are not part of a MarketplaceRevision. Rolling back a storefront design revision must never touch commerce data.
1.1 PaymentPoint
Added 2026-08-18. The leaf of the partner hierarchy: one payment method accepted at one marketplace. A marketplace taking both QR and card has two payment points.
interface PaymentPoint {
id: string;
marketplaceId: string;
method: 'qr' | 'card'; // extensible; both ship today
currencies: string[]; // ISO 4217 subset this channel accepts
externalReference?: string;
status: 'active' | 'suspended' | 'disabled';
providerAccountRef?: string; // set only by financial enablement, never by provisioning
createdAt: string;
updatedAt: string;
}
- Creating a payment point registers the channel. It does not enable real money — that requires
providerAccountRef, set through a separate approved flow. - A payment point is what
RoutingContext.leafNodeIdpoints at (Phase 1 §6.5). MarketplaceFeatureSet.features.paymentsgates whether the marketplace may have enabled payment points at all; the payment point gates which method.
1.2 Backfill
Existing marketplaces predate Company and Project. Migration, in this order:
1. Create one Company for the current owning entity.
2. Create one Project ("marketplaces") under it.
3. Set companyId + projectId on every existing Marketplace.
4. Create PaymentPoints for the methods each marketplace already accepts (qr, card).
5. Make companyId and projectId non-nullable only after 3 completes.
externalReference stays null for backfilled rows — it is only meaningful for partner-provisioned nodes.
2. Lifecycle state machine
draft -> configured -> content_ready -> domains_planned -> staging_live -> qa_passed -> production_ready -> live -> paused/archived
Every state transition endpoint must return the specific blocker preventing the next transition — not just "not ready."
GET /api/admin/v2/marketplaces/{id}/lifecycle -> { currentState, nextState, blockers: string[] }
POST /api/admin/v2/marketplaces/{id}/lifecycle/advance
3. Onboarding wizard (8 steps, plan §4.3)
POST /api/admin/v2/marketplaces -- step 1: name/code/type/owner/countries/locales/currencies/timezone
PATCH /api/admin/v2/marketplaces/{id}/feature-set -- step 2
POST /api/admin/v2/marketplaces/{id}/domains -- step 3
PATCH /api/admin/v2/marketplaces/{id}/design -- step 4
POST /api/admin/v2/marketplaces/{id}/roles -- step 5
PATCH /api/admin/v2/marketplaces/{id}/integrations -- step 6
POST /api/admin/v2/marketplaces/{id}/staging-launch -- step 7, runs smoke tests
POST /api/admin/v2/marketplaces/{id}/production-launch -- step 8, requires all P0 blockers closed + explicit approval
4. Domain automation (Hostinger API, per plan §8.2)
GET /api/dns/v1/zones/{domain}
POST /api/dns/v1/zones/{domain}/validate
PUT /api/dns/v1/zones/{domain}
DELETE /api/dns/v1/zones/{domain}
GET /api/dns/v1/snapshots/{domain}
GET /api/dns/v1/snapshots/{domain}/{snapshotId}
POST /api/dns/v1/snapshots/{domain}/{snapshotId}/restore
Process, strictly in this order:
1. Read current DNS zone.
2. Save a snapshot (rollback payload) BEFORE any change.
3. Build and validate a DNS plan.
4. NEVER touch MX/SPF/DKIM/DMARC/CAA records without a separate, explicitly scoped task.
5. Apply records only after production approval.
6. Verify propagation, SSL issuance, and health checks.
7. Mark the domain 'active' only after all checks in step 6 pass.
5. Publish model
draft -> validation -> preview -> publish
POST /api/admin/v2/marketplaces/{id}/revisions -- create draft
POST /api/admin/v2/marketplaces/{id}/revisions/{revId}/validate
POST /api/admin/v2/marketplaces/{id}/revisions/{revId}/publish -- becomes immutable
POST /api/admin/v2/marketplaces/{id}/revisions/{revId}/rollback -- creates a NEW revision pointing at the prior published content
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:
versionis an integer, assigned asmax(version) + 1for 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
publishedRevisionpointer 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 anHttpOnlycookie scoped to the preview host. Signature comparison is constant-time; an invalid or expired token is404, not401— an unpublished storefront should not confirm its own existence. - While a preview cookie is present, every non-
GETon the public API returns404. 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
GET /api/v2/storefront/bootstrap -- resolved server-side from verified Host header
- Host is normalized and matched against
MarketplaceDomainserver-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.:443andshop.example.comare 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
verifiedAtis set and the marketplace is in a serving state. An unverified domain is404, 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.
Hostis read from the verified proxy header chain, with the proxy configured to overwrite rather than append what the client sent. A client-suppliedHost/X-Forwarded-Hostis not evidence.- No public endpoint accepts a
marketplaceIdin 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.
- Build the Domains & Releases section: DNS/SSL status, staging/production, health checks, rollback.
- Wire the project editor/builder to real revision persistence instead of
localStorage. - Marketplace dashboard: GMV, paid orders, conversion, payment failure rate, moderation queue, low stock, unmatched events, integration health, domain/SSL/release status (plan §4.2).