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:
@@ -51,61 +51,74 @@ Improvements only. Nothing here regresses our Angular version, test count, or ar
|
||||
|
||||
Each item is normative text plus an acceptance scenario in `docs/backend/BACKEND-HANDOFF.md`, so it becomes a delivery gate rather than a wish.
|
||||
|
||||
- [ ] **FH-2.1 — Conditional-UPDATE stock reservation** · S · `PHASE-6-CART-CHECKOUT-CONTRACT.md`
|
||||
- [x] **FH-2.1 — Conditional-UPDATE stock reservation** · S · `PHASE-6-CART-CHECKOUT-CONTRACT.md`
|
||||
**Written 2026-08-21:** PHASE-3 §3.1 — the conditional `UPDATE … WHERE (available - reserved) >= qty RETURNING id`, 409 on zero rows, whole-cart rollback, 15 min TTL.
|
||||
`UPDATE … SET reserved = reserved + $qty WHERE (onHand - reserved) >= $qty RETURNING id`; empty result → `409`. Reservation TTL 15 min. Price read only from the server-side snapshot, never from the request.
|
||||
**Acceptance:** two concurrent purchases of the last unit produce exactly one payable order.
|
||||
|
||||
- [ ] **FH-2.2 — Idempotency as unique constraints** · S · `PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md`
|
||||
- [x] **FH-2.2 — Idempotency as unique constraints** · S · `PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md`
|
||||
**Written 2026-08-21:** PHASE-7 §5 — unique constraints on `payment.idempotency_key` and `(provider, event_key)`, insert-first webhook handling, `sha256(rawBody)` fallback key, signature over the raw body, 24 h poll as reconciliation.
|
||||
`Payment.idempotencyKey UNIQUE`; a key reused against a different order/marketplace → `409`. `PaymentWebhookEvent @@unique([provider, eventKey])`; duplicate insert → `{accepted: true, duplicate: true}`. `eventKey` falls back to `sha256(rawBody)`. Signature verified against the **raw** body. Status poll as a 24-hour reconciliation fallback.
|
||||
**Acceptance:** a replayed webhook neither completes the order twice nor moves stock twice.
|
||||
|
||||
- [ ] **FH-2.3 — Session and credential model** · M · `TRACK-S-SECURITY-RBAC-CONTRACT.md`
|
||||
- [x] **FH-2.3 — Session and credential model** · M · `TRACK-S-SECURITY-RBAC-CONTRACT.md`
|
||||
**Written 2026-08-21:** TRACK-S §2.1 — 32 random bytes stored as SHA-256 only, HttpOnly/Secure/SameSite, one cookie per contour, Argon2id params, mandatory TOTP with a single-use enrolment token, password change revokes all sessions in-transaction.
|
||||
Server-stored sessions; random 32 bytes; **stored as SHA-256 hash only**; HttpOnly + Secure + SameSite; revocable; a distinct cookie per contour (`bo_session` / `manager_session` / `marketplace_session`). Argon2id `memoryCost 65536, timeCost 3, parallelism 1`. TOTP mandatory, gated by a signed 10-minute setup token. Password change ≥16 chars and revokes every live session in the same transaction. Role weights `ORDER_MANAGER 0 < VIEWER 1 < CONTENT_MANAGER 2 < ADMIN 3 < OWNER 4`, checked together with marketplace scope.
|
||||
**Acceptance:** a CONTENT_MANAGER cannot read an unassigned marketplace through a direct API call.
|
||||
|
||||
- [ ] **FH-2.4 — Origin allowlist for admin mutations** · S · `TRACK-S-SECURITY-RBAC-CONTRACT.md`
|
||||
- [x] **FH-2.4 — Origin allowlist for admin mutations** · S · `TRACK-S-SECURITY-RBAC-CONTRACT.md`
|
||||
**Written 2026-08-21:** TRACK-S §2.2 — origin allowlist ahead of routing on every admin/platform/manager mutation, same list for CORS.
|
||||
Global hook: any non-GET on an admin/manager path whose `Origin` is not in the configured allowlist → `403`. CORS uses the same allowlist with `credentials: true`.
|
||||
**Acceptance:** a cross-origin POST with a valid session cookie is refused.
|
||||
|
||||
- [ ] **FH-2.5 — Tenant by verified Host only** · S · `PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md`
|
||||
- [x] **FH-2.5 — Tenant by verified Host only** · S · `PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md`
|
||||
**Written 2026-08-21:** PHASE-9 §6 — normalization specified, `verifiedAt` required, cache with explicit invalidation, proxy header trust, no public endpoint accepts `marketplaceId`.
|
||||
Normalize host (lowercase, strip trailing dot, strip port) → unique `hostname` row → require `verifiedAt` and `ACTIVE`. Short cache with explicit invalidation. Unknown host → `404`, never a fallback tenant. The public API never accepts a `marketplaceId` from the browser.
|
||||
**Acceptance:** an unknown Host returns 404 and leaks no other tenant's data.
|
||||
|
||||
- [ ] **FH-2.6 — Signed preview token, read-only preview** · S · `PHASE-9-…`
|
||||
- [x] **FH-2.6 — Signed preview token, read-only preview** · S · `PHASE-9-…`
|
||||
**Written 2026-08-21:** PHASE-9 §5.3 — HMAC preview token, 15 min, HttpOnly cookie, every non-GET 404s while preview is active, `noindex`.
|
||||
HMAC-signed token carrying `{marketplaceId, expiresAt, nonce}`, 15-minute TTL, `storefront_preview` cookie. Global hook returns `404 Preview mode is read-only` for any non-GET while that cookie is present. Preview is not indexable.
|
||||
**Acceptance:** a mutation attempted in preview mode is refused.
|
||||
|
||||
- [ ] **FH-2.7 — Immutable revisions, rollback, clone** · M · new section, `PHASE-9-…`
|
||||
- [x] **FH-2.7 — Immutable revisions, rollback, clone** · M · new section, `PHASE-9-…`
|
||||
**Written 2026-08-21:** PHASE-9 §5.1–5.2 — `version = max+1` unique per marketplace, materialized snapshot, pointer flipped in-transaction, rollback as a new revision, clone carry/no-carry list, inventory to zero, topological category walk.
|
||||
`version = max(version) + 1`, immutable snapshot row, `publishedRevision` pointer flipped in the same transaction. Rollback creates a new revision; history is never rewritten. Clone copies design + catalog assignments, **forces inventory to 0**, never copies domains/customers/orders/secrets, and walks the category tree topologically with explicit cycle detection.
|
||||
**Acceptance:** rollback restores the chosen revision and leaves live inventory untouched.
|
||||
|
||||
- [ ] **FH-2.8 — Append-only inventory journal** · S · `PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md`
|
||||
- [x] **FH-2.8 — Append-only inventory journal** · S · `PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md`
|
||||
**Written 2026-08-21:** PHASE-3 §3.2 — `InventoryMovement` append-only with reason, reference, actor, and `resultingAvailable` written at the time.
|
||||
Every stock change writes `reason`, `referenceType`, `referenceId`, `actorId`, resulting balance. Direct answer to the v3.1 "we cannot explain your numbers" complaint.
|
||||
**Acceptance:** any current quantity is reconstructible from the journal alone.
|
||||
|
||||
- [ ] **FH-2.9 — Per-tenant encrypted credentials** · S · `PHASE-1` / `PHASE-7`
|
||||
- [x] **FH-2.9 — Per-tenant encrypted credentials** · S · `PHASE-1` / `PHASE-7`
|
||||
**Written 2026-08-21:** TRACK-S §4.2 — `v1.iv.tag.ciphertext` AES-256-GCM envelope, per-value IV, decrypt only in-service, HMAC fingerprints for display, backend-built allowlisted redirect URLs.
|
||||
AES-256-GCM, versioned envelope `v1.iv.tag.ciphertext` (base64url), 32-byte key from the environment. Decrypted only inside the service; never serialized into any response. Redirect/callback URLs built backend-side and allowlisted.
|
||||
**Acceptance:** no credential appears in any API response, JS bundle, or browser storage.
|
||||
|
||||
- [ ] **FH-2.10 — Server-side storefront config validation** · M · `PHASE-10-CONTENT-MODULES-CONTRACT.md`
|
||||
- [x] **FH-2.10 — Server-side storefront config validation** · M · `PHASE-10-CONTENT-MODULES-CONTRACT.md`
|
||||
**Written 2026-08-21:** PHASE-10 §3a — server re-runs the editor rules, clamp-and-fallback ergonomics, structural violations 400, limits published as one schema, referential checks as publish blockers.
|
||||
The server re-runs our editor's validation. Clamp-and-fallback ergonomics: clamp out-of-range numbers rather than rejecting; blank a URL that is not local `/path` or `https://` rather than erroring; fall back an invalid colour. Cap sections per page and IDs per list.
|
||||
**Acceptance:** a hand-crafted API call cannot store a config the editor would have refused.
|
||||
|
||||
- [ ] **FH-2.11 — Digital goods** · M · `PHASE-3-…`
|
||||
- [x] **FH-2.11 — Digital goods** · M · `PHASE-3-…`
|
||||
**Written 2026-08-21:** PHASE-3 §6a — `FulfillmentMode`, `DigitalCode` states, `valueHash` unique per (marketplace, offer), codes revealed only when paid.
|
||||
`FulfillmentMode: MANUAL | CODE_POOL`. `DigitalCode` pool with `AVAILABLE/RESERVED/ASSIGNED/REVOKED`, encrypted value, `valueHash` unique per `(marketplace, variant)`. Codes revealed only when the order is `PAID`/`PROCESSING`/`FULFILLED`.
|
||||
**Acceptance:** an unpaid order never returns a code.
|
||||
|
||||
- [ ] **FH-2.12 — Marketplace status machine** · S · `PHASE-9-…`
|
||||
`DRAFT → DOMAIN_PENDING → READY → ACTIVE → SUSPENDED`, with `DOMAIN_PENDING` as a real state rather than an error condition.
|
||||
- [~] **FH-2.12 — Marketplace status machine** · **rejected 2026-08-21 — ours is better**
|
||||
Theirs is `DRAFT → DOMAIN_PENDING → READY → ACTIVE → SUSPENDED`. PHASE-9 §2 already carries `draft → configured → content_ready → domains_planned → staging_live → qa_passed → production_ready → live → paused/archived`, plus a lifecycle endpoint that must name the specific blocker preventing the next transition. Adopting theirs would be a downgrade. Recorded so it does not get raised again.
|
||||
|
||||
- [ ] **FH-2.13 — Order public token, not sequential IDs** · S · `PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md`
|
||||
- [x] **FH-2.13 — Order public token, not sequential IDs** · S · `PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md`
|
||||
**Written 2026-08-21:** PHASE-2 §3.1 — `publicToken` ≥24 random bytes for every customer-facing route, tenant-scoped lookup, snapshot completeness, snapshots never updated in place.
|
||||
Orders are addressed publicly by a random `base64url` token. Order line items carry an immutable snapshot of name, SKU, price, currency, delivery, and contact data at purchase time.
|
||||
|
||||
- [ ] **FH-2.14 — Order-manager as a separate contour** · M · `TRACK-S-…`
|
||||
- [x] **FH-2.14 — Order-manager as a separate contour** · M · `TRACK-S-…`
|
||||
**Written 2026-08-21:** TRACK-S §8a — separate URL, shell, login and cookie; scope from membership rows not configuration; endpoints refuse rather than hide; PII masking and audited reveal.
|
||||
Separate URL, shell, cookie, and login; scoped to assigned marketplaces via **membership rows, not an environment variable** (their env-pinned slug is the one part not to copy). No visibility into catalog, design, domains, payment settings, or platform users. PII masked in lists, revealed in detail only with permission, and both export and reveal are logged.
|
||||
|
||||
- [ ] **FH-2.15 — CSV marketplace import with `dryRun` default true** · S · `PARTNER-PROVISIONING-API-CONTRACT.md`
|
||||
Bulk tenant creation as a first-class operation: validate fully without writing, report create/update/skip/error per row, then confirm.
|
||||
- [x] **FH-2.15 — Bulk import: idempotency and rollback** · S · **done 2026-08-21**
|
||||
The validate-then-apply half already existed — PHASE-3 §6 has the preview of validation errors and a separate apply step, which is equivalent to their `dryRun`. What was missing and is now written: the import is **idempotent by SKU/external key** so re-running a file updates rather than duplicates, a row-level error never publishes a partial result, and an applied import is rollback-able only while none of its products have appeared on a paid order.
|
||||
|
||||
---
|
||||
|
||||
@@ -195,18 +208,17 @@ Blocked on **FH-0.1**. Nothing to copy from the archive — it has zero VK/Yande
|
||||
|
||||
## Continuous — Process (Lane E)
|
||||
|
||||
- [ ] **FH-E.1 — Adopt the nine invariants as a signed acceptance gate** · S
|
||||
From their handoff §7: tenant by Host not by a browser-supplied ID; order price computed backend; stock and reservation atomic; payment webhook idempotent; credentials never leave the backend; published revision immutable; design rollback does not roll back live inventory; no user reads an unassigned marketplace through UI or API; every admin mutation leaves an audit trail.
|
||||
Put them at the head of `docs/backend/BACKEND-HANDOFF.md` as gates, not aspirations.
|
||||
- [x] **FH-E.1 — Adopt the nine invariants as an acceptance gate** · S · **done 2026-08-21**
|
||||
Now `BACKEND-HANDOFF.md` §0, ahead of everything else, each one cross-referenced to the contract section that specifies it. Framed as a release gate: violate one and it does not ship, regardless of what else is finished.
|
||||
|
||||
- [ ] **FH-E.2 — PR policy** · S
|
||||
One functional area per PR. Mandatory: purpose, screenshots, API changes, migrations, test evidence, security impact, rollback plan. Never change payment/inventory/order state machines inside a redesign PR.
|
||||
- [x] **FH-E.2 — PR policy** · S · **done 2026-08-21**
|
||||
`BACKEND-HANDOFF.md` §0a, with the expand/contract migration rule alongside it.
|
||||
|
||||
- [ ] **FH-E.3 — Release discipline** · S
|
||||
"A local build or the existence of a UI does not mean production readiness." Every release records version, migrations, healthcheck, smoke result, dependency audit, and a rollback path.
|
||||
- [x] **FH-E.3 — Release discipline** · S · **done 2026-08-21**
|
||||
`BACKEND-HANDOFF.md` §0a. A release records version, migrations, healthcheck, smoke, dependency audit, and the rollback path actually available.
|
||||
|
||||
- [ ] **FH-E.4 — ADR for the harvest** · S
|
||||
Record the decision: adopt these improvements, reject their architecture, keep our frontend and governance. Include the §9 rejection list so it does not get relitigated.
|
||||
- [x] **FH-E.4 — ADR for the harvest** · S · **done 2026-08-21**
|
||||
[ADR-0006](context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md). Records what we take, what we reject, what we keep because ours is better, and the one organizational question it deliberately does not settle.
|
||||
|
||||
- [ ] **FH-E.6 — Keep mock gateways out of production builds** · M · Lane A
|
||||
Measured 2026-08-21: mock seed data reaches the production bundle. `ptr_local`, a fixture literal from `partner-hierarchy-local.gateway.ts`, is present in a built lazy chunk. Cause: 21 DI tokens use `factory: () => (environment.useMockData ? inject(XLocalGateway) : inject(XApiGateway))`, and referencing both branches keeps both classes reachable, so the optimizer cannot drop the mock. 75 kB of local-gateway source, plus its fixtures, ships to users.
|
||||
@@ -222,15 +234,27 @@ Blocked on **FH-0.1**. Nothing to copy from the archive — it has zero VK/Yande
|
||||
|
||||
## Scoreboard
|
||||
|
||||
| Wave | Items | Lane | Blocked by |
|
||||
|---|---:|---|---|
|
||||
| 0 — Decide | 2 | C, A | — |
|
||||
| 1 — Live defects | 4 | A | FH-0.2 (one item) |
|
||||
| 2 — Contracts | 15 | B | — |
|
||||
| 3 — Proof | 5 | A | — |
|
||||
| 4 — Identity | 8 | C | FH-0.1 |
|
||||
| Ops | 3 | D | — |
|
||||
| Process | 6 | E | — |
|
||||
| **Total** | **43** | | |
|
||||
| Wave | Done | Open | Lane | Blocked by |
|
||||
|---|---:|---:|---|---|
|
||||
| 0 — Decide | 0 | 2 | C, A | — |
|
||||
| 1 — Live defects | 2 | 2 | A | FH-1.2 and FH-1.4 sit in files another session owns |
|
||||
| 2 — Contracts | 14 | 0 | B | — (1 rejected: FH-2.12) |
|
||||
| 3 — Proof | 2 | 3 | A | — |
|
||||
| 4 — Identity | 0 | 8 | C | FH-0.1 |
|
||||
| Ops | 0 | 3 | D | — |
|
||||
| Process | 4 | 2 | E | — |
|
||||
| **Total** | **22** | **20** | | 1 rejected |
|
||||
|
||||
**Start here:** FH-0.1 (escalate today, it is a one-way door), then FH-1.1 and FH-1.4 — both are small, both are live defects, and both close findings their audit will otherwise keep raising.
|
||||
**Landed 2026-08-21**
|
||||
|
||||
- **Wave 1:** FH-1.1 (geo off `ip-api.com`, 4 new tests), FH-1.3 (credentials out of the browser, via the `@marketplaces/payment` migration).
|
||||
- **Wave 2:** all 14 remaining contract items written into `docs/backend/`, tagged `FH-*` and dated so each traces back to the analysis. FH-2.12 rejected on the merits — our lifecycle state machine is richer than theirs.
|
||||
- **Wave 3:** FH-3.3 (bundle budget ratcheted to a blocking error), FH-3.5 (`scripts/ci/scan-bundle.sh`, wired into CI, verified in both directions).
|
||||
- **Process:** FH-E.1–E.4, including [ADR-0006](context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md).
|
||||
|
||||
**Next**
|
||||
|
||||
1. **FH-0.1** — the central identity host. One-way door, blocks all eight Wave 4 items, needs a person not a session.
|
||||
2. **FH-3.1 / FH-3.2** — the two acceptance e2e tests. Both contracts they prove (PHASE-3 §3.1, PHASE-7 §5) are now written, so the tests have something normative to assert against.
|
||||
3. **FH-1.2 / FH-1.4** — bank URL validation and `Idempotency-Key`. Both live in `cart.component.ts` / the payment package; pick them up once that work settles.
|
||||
4. **FH-E.6** — mock fixtures currently reach production chunks. Mechanical fix, pattern already in the repo.
|
||||
|
||||
Reference in New Issue
Block a user