# Blueprint: `marketplace` (the `commerce` ∪ `community` two-preset compose)

> Audience: AI coding agents first. A blueprint is a **worked example** in `apps/consumer`, not a
> shipped product (delivery-stack §4.1). This one is the **compose-blueprint** shape (§4.1.1): it
> composes the two presets that each already have a hand-built blueprint. If you find yourself adding a
> storefront, seller dashboard, payout schedule, or an orders table here — stop, that is host-owned.

## Preset — there is NO `presets.marketplace`

Marketplace is the **UNION** of two presets, not a fifth preset:

```
registry.json → presets.commerce  = util · db · billing · tax · ledger · uploads · search
registry.json → presets.community = util · db · mail · auth · realtime · notifications · search · uploads · audit
─────────────────────────────────────────────────────────────────────────────────────────────────
marketplace (union, 12 modules)   = util · db · billing · tax · ledger · uploads · search
                                    + mail · auth · realtime · notifications · audit
shared by both (overlap)          = util · db · uploads · search
```

**Two separate facts, stated precisely (do not conflate — §4.1.1):**
- **Runtime compose** (what the test proves): the real preset seams compose into one flow and the
  seams between them hold. Proven in `tests/blueprint-marketplace.test.ts`.
- **Version-consistency** of the four shared modules (`util`/`db`/`uploads`/`search`): trivially true
  under `workspace:*` — one version each in `apps/consumer/package.json`, so the union cannot pull two
  copies. This is verified by grepping the two preset arrays, NOT something the test exercises.

## The §4.1.1 compose shape — reuse, don't re-author

This blueprint **imports the commerce + community wiring seams verbatim** and authors **only** the
compose-distinct seams. That reuse IS the compose proof: the test drives the *real* preset seams, not
forked copies (forks would leave the composition claim unverified).

| Seam | Source | Why |
|---|---|---|
| auth (`createFakeAuthEngine`, `bearer`) | reused `../community/wiring/auth` | the buyer gate is community's exact single-scope auth |
| mail (`createCaptureMail`) | reused `../community/wiring/mail` | backs the notifications email channel |
| notifications (`createFollowerNotifications`) | reused `../community/wiring/notifications` | seller notice; **one sanctioned edit** (below) |
| audit (`createAuditDb`) | reused `../community/wiring/audit` | records the settled order |
| uploads (`validateProductImage`) | reused `../commerce/wiring/uploads` | listing trust boundary (proven there; touched here) |
| search (`createProductIndex`) | reused `../commerce/wiring/search` | catalog index |
| tax (`resolveInclusiveOrderTax`) | reused `../commerce/wiring/tax` | inclusive split pre-charge (Pattern C) |
| billing (`createCaptureProvider`) | reused `../commerce/wiring/billing` | the buyer charge (provider seam) |
| ledger (`createCommerceLedger`) | reused `../commerce/wiring/ledger` | the journal the split writes through |
| **split** (`computeSplit`, `recordMarketplaceSplit`, `readSplitEntries`) | **authored** `./wiring/split` | **the marketplace-distinct domain** |
| **realtime** (`orderSettledEvent`) | **authored** `./wiring/realtime` | **seller-private settled envelope** |

**The one sanctioned reuse-edit (§4.1.1):** community's `createFollowerNotifications` gained a
**backward-compatible** `enabled` event-map param (defaulted to its own `post.published`), so
marketplace can drive it for `order.sold`. That was the **only** change reuse required — community's
test still passes on the default (re-run, verified). A second consumer needing exactly one defaulted
param is the §7 convergence signal; needing more would have meant duplication was cleaner.

## The worked flow — one charge, split two ways

The headline marketplace-distinct property is the **multi-party SPLIT**: a single buyer charge is
allocated across the seller + the platform as **two idempotent ledger entries that sum to the gross**.

```
community/auth (may this buyer order?) ── GATE (fail-closed across both presets)
  → commerce/tax: split inclusive gross → net + vat PRE-CHARGE (Pattern C)
    → commerce/billing PROVIDER.charge: the buyer pays the gross
      → marketplace/split: TWO ledger entries  +seller_payout  +platform_take   (Σ = gross)
        → community/audit: record the settled order
          → community/notifications: notify the SELLER (email channel composes mail)
            → marketplace/realtime: seller-PRIVATE order-settled envelope (targetUserId-scoped)
```

Worked numbers (₪118 inclusive = ₪100 net + 18% VAT, 10% commission): seller payout **9000** agorot
(net − commission), platform take **2800** (commission 1000 + VAT 1800), Σ = **11800** = gross.

**Why `provider.charge`, NOT `billing.settleCharge`:** `settleCharge` funnels a *single* `+gross`
entry into the injected ledger seam (the commerce blueprint's single-party shape). A marketplace must
**split** the proceeds, so the host composes billing's **provider** seam (the buyer charge) with its
**own** ledger split (the allocation). Using the provider directly also avoids the `LedgerSeam`
generic-variance cast the commerce funnel needs — `appendEntry` is called directly and unifies cleanly.

## The split lives at TWO altitudes (a real architectural nuance, grounded in prior production code)

1. **Provider-native (Pattern A — provider-in-charge → config, NOT a module):** the Sumit adapter
   already settles to multiple vendor sub-accounts + a platform-fee leg via `req.sumit.vendorItems` +
   `platformAmountMinor` (`packages/billing/src/sumit.ts`); Stripe Connect does the same via
   `transfer_data`/`application_fee`. When the processor splits natively, that is **billing config**,
   not a new module — exactly Pattern A.
2. **Ledger-domain (this blueprint, `wiring/split.ts`):** the platform's **own** append-only record of
   who is owed what — the **universal** source of truth that holds for *any* processor, including a
   plain charge that does not split natively. This is the split the blueprint exercises;
   altitude #1 is NAMED here, not wired (wiring the full Sumit adapter is provider-specific bloat).

**The VAT-in-platform-take split is illustrative host domain, not a prescriptive VAT model.**
Merchant-of-record vs facilitator VAT liability varies by jurisdiction and contract; the blueprint
proves only "one charge → N idempotent ledger entries via the seam, summing to gross," not who legally
remits the VAT. A real marketplace sets that per its own tax counsel.

## Three properties under test

1. **Union compose + split** — the valid path touches all 12 union modules and asserts the split
   persisted with the right party tags (`seller_payout` 9000 / `platform_take` 2800), summing to gross.
2. **Idempotency across split AND comms** — re-purchasing the same `orderId` leaves the journal at
   **two** entries (`onConflictDoNothing`) and sends the seller **one** email (`order:<id>` dedup key
   collides). The fake provider records two charge calls — its own naivety, **not** a double-charge;
   real *provider* idempotency is the host's job via `chargeKey` (a separate guarantee from the
   ledger-seam dedup this proves).
3. **Fail-closed across BOTH presets** — an unauthorized buyer is rejected at the **community** auth
   gate before any **commerce** side effect: zero charges, zero ledger entries, zero notices, zero
   audit rows. A community boundary protecting a commerce money flow is the whole point of the compose.

## Realtime — the seller-private envelope (the `/server` scope boundary)

this blueprint scopes realtime to the **typed envelope only** (the built `/server` Durable-Object/Queue
delivery is host-wired — out of this recipe's scope), so the flow builds the typed envelope and the test
asserts it is well-formed; it does **not** deliver. The marketplace envelope sets **`targetUserId: sellerId`** —
contrast community's `postPublishedEvent`, which is public and scope-wide (no `targetUserId`). That one
field is the difference between a private payout notice and a public broadcast; per the module
contract (`RealtimeEvent.targetUserId`: "set ⇒ user-scoped delivery only"), getting it wrong leaks a
seller's revenue to every connected client. A real host delivers it through `@platform-modules/realtime/server` once that subpath ships.

## Blueprint-owned vs host-owned boundary

- **Blueprint-owned (here):** the two authored compose-distinct seams (`split`, `realtime`) + the one
  composition test. Everything else is reused preset wiring.
- **Host-owned (NAMED, not implemented):** the storefront / seller dashboard UI (host-copied shadcn
  over Radix — no UI ships), routes/endpoints (web-standard handlers), the order + product + payout
  entities, the commission-rate authority, the VAT-liability accounts, payout scheduling, and the real
  PaymentProvider (incl. its native vendor-split config), R2/S3 storage, FTS provider, and realtime
  `/server` delivery — all injected where the fakes sit.

## What this blueprint is NOT

A package · a published artifact (blueprints are NEVER in the publish set — only the modules ship) ·
a fifth preset · styled UI / routes / entity schemas · a generator.

## Convergence note (delivery-stack §7 — the generator-readiness CONCLUSION)

**Fourth and final hand-built blueprint — this is the convergence verdict step 4 of §7 calls for.**
The four blueprints reveal **two distinct scaffold shapes**, not one:

- **Single-preset shape (saas-admin, community, commerce):** the §4.1 four-artifact form — one
  `wiring/<module>.ts` per preset module + one gate→side-effects composition test. These three DID
  converge: the auth gate, the uploads trust boundary, the search index, and the pglite-backed
  audit/ledger stand-ups are structurally identical across them (only the noun changes).
- **Compose shape (marketplace):** the §4.1.1 form — reuse constituent wiring + author only the
  compose-distinct domain seam(s). Fundamentally different: it has no per-module wiring fan-out at all.

**Verdict: a generator is justified ONLY for the single-preset shape, and even there it is deferred.**
The three single-preset blueprints share enough structure that a generator *could* template "preset →
per-module wiring stubs + a gate→side-effects test skeleton." But (a) the compose shape is a separate
template the convergence evidence does **not** yet cover (one example is not convergence), and (b) the
goal that produced these blueprints was explicitly **hand-assembly only, no generator** — so this note
is a *finding*, not a build trigger. Per Gate-1, generator code stays rejected until a real consumer
demand for scaffolding appears; convergence is now *recorded*, to be acted on then, not now.
