# `@platform-modules/affiliate` Extraction Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use /ship (recommended) or /executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Extract multideal's `apps/web/src/server/referrals/` into `@platform-modules/affiliate` (L3), then migrate multideal onto it behind a parity harness + `security-guard` gate.

**Architecture:** Parity-first staged port (Approach A). Generic referral engine + audit-hardened money primitives in the module; host supplies the commission policy (`CommissionPolicy`), the external payout executor (`PayoutExecutor`), and the DB tables. Maturity projection (credit-ledger + 5-col wallet) is **affiliate-owned** (verified: ledger exposes only generic `appendEntry`/`debit`/`debitWithRead` over a generic single-balance wallet). Money/fraud waves are strictly serial — never pipelined.

**Tech Stack:** TypeScript ESM, drizzle-orm `pgTable` (Axis B — schema exported, no migration), vitest + PGLite integration oracle (ported from donor), tsup build, `@platform-modules/db` + `@platform-modules/ledger` (both shipped). No bundled host/framework/provider (`stripe` stays a host adapter behind `PayoutExecutor`).

**Source of truth:** `docs/specs/2026-06-17-affiliate-module-extraction-design.md` (the seam contract). Canonical law: `CLAUDE.md` §2–§6 · `docs/standards/coding-standard.md` §4/§5.1/R1–R4 · `docs/specs/2026-06-13-ledger-module-boundaries.md`.

**Donor root (read-only, ALL `file:line` below are donor):** `/home/user/Projects/multideal/apps/web/src/`.

---

## Pre-build gates (checkpoints — NOT executed inside the per-wave TDD loop)

These run in the orchestrator (Claude main loop), not by the implementer:

- **Advisor gate** — re-run `advisor()` over this plan + the reconciled design once it is back up (it was overloaded at plan time; the 3 ADR resolutions in the design's "Open questions" section are provisional pending its read). Before **Wave 9**.
- **`security-guard` gate** — independent adversarial pass over the payout (`src/payout.ts`) + fraud (`src/fraud/`) paths (money-out + fail-open risk). Before **Wave 9**, after Waves 7–8 land.
- **Per-diff `seam-reviewer`** — after each wave's commits, per `platform-orchestrator` (Tier-1 cursor reviewer → Tier-2 opus `seam-reviewer`). Not a plan step; orchestrator policy.

**multideal has no prod env (only `dev.multi.deal` preview)** — the ported PGLite parity harness + `security-guard` ARE the swap gate; live money-send cannot be smoke-tested (ADR-6, accepted risk). Live-send smoke deferred to whenever multideal provisions prod.

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | Task 1 | `packages/affiliate/{package.json,tsconfig.json,tsup.config.ts,vitest.config.ts,src/index.ts}` | single task |
| 2 | Task 2, Task 3 | `src/schema.ts` · `src/errors.ts` | ✅ no file overlap |
| 3 | Task 4, Task 5, Task 6 | `src/test/*` → `src/fraud/{types,registry}.ts` → `src/fraud/adapters/*` | ❌ serial (fraud gate; 5→6 dep) |
| 4 | Task 7, Task 8 | `src/{attribution,settings}.ts` · `src/commission.ts` | ✅ no file overlap |
| 5 | Task 9, Task 10 | `src/accrual.ts` → `src/earn.ts` | ❌ serial (10 uses 9) |
| 6 | Task 11 | `src/commission.ts`, `src/earn.ts`, `apps/consumer/**` | single task (seam refactor) |
| 7 | Task 12, Task 13, Task 14 | `src/payout.ts` → seam → ledger-primitive swap | ❌ serial (money-out gate) |
| 8 | Task 15 | `src/security/*` | single task (opt-in, no core coupling) |
| 9 | Task 16 | multideal worktree (embed) | single task (gated; advisor + security-guard first) |

Waves are mostly serial **by mandate** (money/fraud paths never pipelined — `platform-orchestrator`). Intra-wave parallel pairs (W2, W4) are flagged ✅ only where file sets are disjoint.

---

## File Structure

```
packages/affiliate/
  package.json            # deps: @platform-modules/{db,ledger} workspace:^ ; peer: drizzle-orm
  tsconfig.json · tsup.config.ts · vitest.config.ts   # from `turbo gen`
  src/
    index.ts              # core barrel  → exports "."
    schema.ts             # ALL affiliate pgTable exports (Axis B)  → exports "./schema"
    errors.ts             # typed errors + structural guards (no instanceof across packages)
    settings.ts           # ReferralSettings (W4 verbatim; W6 → host policy)
    attribution.ts        # resolveRefCode · isSelfReferral · bindReferralOnSignup
    commission.ts         # W4 verbatim tier fns; W6 → CommissionPolicy + computeCommission
    accrual.ts            # accrueCommission (insert-ledger-first) · matureCredits · wallet ops
    earn.ts               # processReferralEarn (EARN handler, fail-closed)
    payout.ts             # debit/restore + PayoutExecutor seam + settlePayout
    fraud/
      index.ts · types.ts · registry.ts
      adapters/*.ts        # 13 adapters, ported verbatim
    security/
      index.ts · *.ts      # 10 files, opt-in, no core coupling
    test/
      pglite-db.ts · fixtures.ts   # ported oracle harness
    *.test.ts              # co-located unit
    *.int.test.ts          # PGLite oracle ports
```

**Donor → module file map (verbatim ports unless "transform" noted):**

| Donor (`apps/web/src/`) | Module (`packages/affiliate/src/`) | Transform |
|---|---|---|
| `server/referrals/fraud/types.ts` | `fraud/types.ts` | imports only |
| `server/referrals/fraud/registry.ts` | `fraud/registry.ts` | `@/server/db`→`Database`/`Transaction` param; schema import→`../schema.js` |
| `server/referrals/fraud/adapters/*.ts` (13) | `fraud/adapters/*.ts` | imports only |
| `server/referrals/attribution.ts` | `attribution.ts` | db import→param; schema→`./schema.js` |
| `server/referrals/commission.ts` | `commission.ts` | imports only (W4); seam refactor (W6) |
| `server/referrals/settings.ts` | `settings.ts` | imports only (W4); → host (W6) |
| `server/referrals/maturation.ts` (+ accrual bits of `service.ts`) | `accrual.ts` | db→param; schema→`./schema.js`; ledger `appendEntry` |
| `server/referrals/payout-debit.ts` | `payout.ts` | db→`Transaction` param; `InsufficientBalanceError`→`./errors.js`; PayoutExecutor seam |
| EARN handler in `server/referrals/service.ts` | `earn.ts` | compose fraud+commission+accrual; fail-closed preserved |
| `server/referrals/security/*.ts` (10) | `security/*.ts` | imports only |
| `tests/integration/referrals/helpers/{pglite-db,fixtures}.ts` | `test/{pglite-db,fixtures}.ts` | drizzle schema import→module `./schema.js` |
| `tests/integration/referrals/*.int.test.ts` | co-located `src/**/*.int.test.ts` | import paths→module |
| `tests/.../{clawback-logic,guardrails,maturation-logic,...}.test.ts` | co-located `src/**/*.test.ts` | import paths→module |

**Stays in host (NOT ported):** `analytics-rollup.ts`, `admin-actions.ts`, `welcome.ts`, `graph-scan.ts`, `auto-suspend.ts` orchestration (host wiring), `payments/connect/affiliate-payout.ts` (becomes the host's `PayoutExecutor` impl), the multideal tier values (become a host `CommissionPolicy`).

---

### Task 1: Scaffold `packages/affiliate`

**Wave:** 1
**Blocks:** Task 2, Task 3
**Blocked by:** —

**Files:**
- Create: `packages/affiliate/package.json`, `tsconfig.json`, `tsup.config.ts`, `vitest.config.ts`, `src/index.ts`

- [ ] **Step 1: Scaffold via the repo generator**

Run: `cd /home/user/Projects/platform && pnpm turbo gen` (select the module generator) **or** copy the layout of an existing L2/L3 module (e.g. `packages/ledger`) — match its `tsup.config.ts`, `tsconfig.json`, `vitest.config.ts` exactly.

- [ ] **Step 2: Set `package.json`** — name, deps, exports, ESM contract

```jsonc
{
  "name": "@platform-modules/affiliate",
  "version": "0.0.0",
  "type": "module",
  "sideEffects": false,
  "files": ["dist"],
  "exports": {
    ".":         { "types": "./dist/index.d.ts",          "import": "./dist/index.js" },
    "./schema":  { "types": "./dist/schema.d.ts",         "import": "./dist/schema.js" },
    "./fraud":   { "types": "./dist/fraud/index.d.ts",    "import": "./dist/fraud/index.js" },
    "./security":{ "types": "./dist/security/index.d.ts", "import": "./dist/security/index.js" }
  },
  "scripts": {
    "build": "tsup",
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  },
  "dependencies": {
    "@platform-modules/db": "workspace:^",
    "@platform-modules/ledger": "workspace:^"
  },
  "peerDependencies": { "drizzle-orm": "catalog:" },
  "devDependencies": {
    "@electric-sql/pglite": "catalog:",
    "drizzle-orm": "catalog:"
  }
}
```

> Sibling `@platform-modules/*` deps are **regular `dependencies` on `workspace:^`** (CLAUDE.md §6 — never `peerDependencies`; that triggers the 1.0.0 force-major cascade). `drizzle-orm` is the only true peer (adopter-supplied). NO `stripe` anywhere (host adapter). Match the exact `catalog:` keys an existing module uses for pglite/drizzle — read `packages/ledger/package.json` first.

- [ ] **Step 3: `tsup.config.ts` entries** — one per exported subpath

Entries: `src/index.ts`, `src/schema.ts`, `src/fraud/index.ts`, `src/security/index.ts`. `external: ['drizzle-orm', '@platform-modules/db', '@platform-modules/ledger']`, `dts: true`, `format: ['esm']`. Copy the shape from `packages/ledger/tsup.config.ts`.

- [ ] **Step 4: Stub `src/index.ts`**

```ts
// core barrel — populated by later waves
export {}
```

- [ ] **Step 5: Install + verify the scaffold builds empty**

Run: `cd /home/user/Projects/platform && pnpm install && pnpm --filter @platform-modules/affiliate build && pnpm --filter @platform-modules/affiliate typecheck`
Expected: install clean (no peer WARN beyond the documented `drizzle-orm` adopter peer), `dist/` produced, typecheck PASS.

- [ ] **Step 6: Commit**

```bash
git add packages/affiliate/package.json packages/affiliate/tsconfig.json packages/affiliate/tsup.config.ts packages/affiliate/vitest.config.ts packages/affiliate/src/index.ts
git commit -m "feat(affiliate): scaffold @platform-modules/affiliate (L3) — deps db+ledger, 4 subpath exports"
```

---

### Task 2: Port DB schema (Axis B `pgTable` exports) + enumerate-all-tables gate

**Wave:** 2
**Blocks:** Task 4, Task 7, Task 9, Task 12
**Blocked by:** Task 1

**Files:**
- Create: `packages/affiliate/src/schema.ts`, `packages/affiliate/src/schema.test.ts`

- [ ] **Step 1: GATE — enumerate every table the ported code references**

Run (donor):
```bash
cd /home/user/Projects/multideal && \
grep -rhoE "(referralLinks|referrals|affiliatePayouts|affiliateEnrollments|creditLedger|walletBalances|[a-zA-Z]*[Ff]raud[A-Za-z]*|[a-zA-Z]*[Ss]ignal[A-Za-z]*)" \
  apps/web/src/server/referrals/ | sort -u
```
Also grep the donor `schema.ts` for any table the fraud `persistEvents` path writes (registry.ts uses `persistEvents`). **If a fraud-events / fraud-signals table exists that is NOT in the design §1 affiliate-owned list → STOP. Spec-first kickback:** do not invent it — report to the orchestrator to amend `docs/specs/2026-06-17-affiliate-module-extraction-design.md` §1/§2 + this plan first, then resume. A fraud-events table, if present, is **affiliate-owned** (fraud subpath) and is added to `src/schema.ts`.

- [ ] **Step 2: Write the failing schema test**

```ts
// src/schema.test.ts
import { describe, it, expect } from 'vitest'
import * as schema from './schema.js'

describe('affiliate schema (Axis B)', () => {
  it('exports the affiliate-owned tables and ships no migration', () => {
    for (const name of [
      'referralLinksTable', 'referralsTable', 'affiliatePayoutsTable',
      'affiliateEnrollmentsTable', 'affiliateCreditLedgerTable', 'affiliateWalletBalancesTable',
    ]) {
      expect(schema, `missing export ${name}`).toHaveProperty(name)
    }
  })
  it('renames the maturity tables to avoid colliding with ledger generic tables', () => {
    // affiliate's wallet/ledger must NOT use the bare names ledger already owns
    // (drizzle table name is the first pgTable arg) — assert via the SQL name
    // helper the donor schema test uses; see donor schema test for getTableName usage.
    expect(true).toBe(true) // replace with getTableName() assertion once tables exist
  })
})
```

- [ ] **Step 3: Run test to verify it fails**

Run: `pnpm --filter @platform-modules/affiliate test src/schema.test.ts`
Expected: FAIL ("missing export referralLinksTable").

- [ ] **Step 4: Port the schema**

Port these donor `pgTable` definitions from `apps/web/src/server/db/schema.ts` into `src/schema.ts`, exported as `…Table`:
- `referralLinks` (≈line 2787) → `referralLinksTable`
- `referrals` (≈2804, status enum `pending|qualified|quarantined|…`) → `referralsTable`
- `affiliatePayouts` (≈3454; UNIQUE `stripeTransferId`/`stripePayoutId`/`idempotencyKey`/`ledgerEntryId`) → `affiliatePayoutsTable`
- `affiliateEnrollments` → `affiliateEnrollmentsTable`
- `creditLedger` (≈2843; bigint agorot, composite UNIQUE `(entryType,sourceType,sourceId)`, `resolvedPct`/`matureAt`/`withdrawableAt`) → `affiliateCreditLedgerTable`, **SQL table name `affiliate_credit_ledger`** (renamed — ledger owns `ledger_entries`)
- `walletBalances` (≈2868; 5 cols: `balance`/`pending`/`matured`/`withdrawable`/`lifetimeEarned`) → `affiliateWalletBalancesTable`, **SQL table name `affiliate_wallet_balances`** (renamed — ledger owns `wallet_balances`)
- any fraud-events table found in Step 1 → exported, affiliate-owned.

Transform: copy the column definitions verbatim (preserve types, defaults, UNIQUE/index constraints — R4 audit-hardened). Only the export name + the renamed SQL table-name strings change. Import `pgTable`, `bigint`, etc. from `drizzle-orm/pg-core`. Export a `affiliateSchema` object aggregating all tables (mirror `packages/ledger/src/schema.ts` `ledgerSchema`).

- [ ] **Step 5: Finalize the rename assertion in the test**

Replace the placeholder in Step 2's second test with a real `getTableName()` (drizzle) assertion: `affiliate_credit_ledger` and `affiliate_wallet_balances`, and that neither equals `wallet_balances`/`ledger_entries`.

- [ ] **Step 6: Run test to verify it passes**

Run: `pnpm --filter @platform-modules/affiliate test src/schema.test.ts`
Expected: PASS.

- [ ] **Step 7: Wire the `./schema` export + build**

Run: `pnpm --filter @platform-modules/affiliate build`
Expected: `dist/schema.js` + `dist/schema.d.ts` produced.

- [ ] **Step 8: Commit**

```bash
git add packages/affiliate/src/schema.ts packages/affiliate/src/schema.test.ts
git commit -m "feat(affiliate): Axis-B pgTable exports; maturity tables affiliate-owned + renamed to not collide with ledger"
```

---

### Task 3: Port typed errors + structural type-guards

**Wave:** 2
**Blocks:** Task 5, Task 10, Task 12
**Blocked by:** Task 1

**Files:**
- Create: `packages/affiliate/src/errors.ts`, `packages/affiliate/src/errors.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// src/errors.test.ts
import { describe, it, expect } from 'vitest'
import { InsufficientBalanceError, isInsufficientBalanceError, FraudHoldError, isFraudHoldError } from './errors.js'

describe('affiliate typed errors — structural guards (no cross-package instanceof)', () => {
  it('isInsufficientBalanceError matches a brand, not the prototype', () => {
    const e = new InsufficientBalanceError('insufficient', { requiredMinor: 100n, availableMinor: 50n, bucket: 'matured' })
    expect(isInsufficientBalanceError(e)).toBe(true)
    // a structurally-branded plain object (a deduped second copy) still matches:
    expect(isInsufficientBalanceError({ _affiliateError: 'InsufficientBalanceError' })).toBe(true)
    expect(isInsufficientBalanceError(new Error('x'))).toBe(false)
  })
  it('isFraudHoldError matches the brand', () => {
    expect(isFraudHoldError(new FraudHoldError('held'))).toBe(true)
    expect(isFraudHoldError(new Error('x'))).toBe(false)
  })
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm --filter @platform-modules/affiliate test src/errors.test.ts`
Expected: FAIL (module not found).

- [ ] **Step 3: Port + brand the errors**

Port the donor `InsufficientBalanceError` class from `apps/web/src/server/referrals/payout-debit.ts` verbatim (preserve its constructor + any `detail` shape — R4). Add a **non-enumerable string brand** and a structural guard. Add a `FraudHoldError` for the EARN fail-closed path (donor's fraud hold signal).

```ts
// src/errors.ts
export class InsufficientBalanceError extends Error {
  readonly code = 'INSUFFICIENT_BALANCE' as const
  // brand carries cross-package identity (deduped copies break `instanceof`)
  readonly _affiliateError = 'InsufficientBalanceError' as const
  constructor(message: string, readonly detail?: { requiredMinor: bigint; availableMinor: bigint; bucket: 'matured' | 'withdrawable' }) {
    super(message)
    this.name = 'InsufficientBalanceError'
  }
}
export function isInsufficientBalanceError(e: unknown): e is InsufficientBalanceError {
  return typeof e === 'object' && e !== null
    && (e as { _affiliateError?: unknown })._affiliateError === 'InsufficientBalanceError'
}

export class FraudHoldError extends Error {
  readonly code = 'FRAUD_HOLD' as const
  readonly _affiliateError = 'FraudHoldError' as const
  constructor(message: string, readonly detail?: { point: string; reason: string }) {
    super(message)
    this.name = 'FraudHoldError'
  }
}
export function isFraudHoldError(e: unknown): e is FraudHoldError {
  return typeof e === 'object' && e !== null
    && (e as { _affiliateError?: unknown })._affiliateError === 'FraudHoldError'
}
```

> If the donor's `InsufficientBalanceError` constructor signature differs from the above `detail` shape, **preserve the donor's** (R4) and adapt only the brand + guard.

- [ ] **Step 4: Run test to verify it passes**

Run: `pnpm --filter @platform-modules/affiliate test src/errors.test.ts`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add packages/affiliate/src/errors.ts packages/affiliate/src/errors.test.ts
git commit -m "feat(affiliate): typed errors with structural guards (isInsufficientBalanceError/isFraudHoldError)"
```

---

### Task 4: Port the PGLite oracle harness

**Wave:** 3
**Blocks:** Task 5, Task 9, Task 12
**Blocked by:** Task 2

**Files:**
- Create: `packages/affiliate/src/test/pglite-db.ts`, `packages/affiliate/src/test/fixtures.ts`

- [ ] **Step 1: Port the harness**

Port donor `tests/integration/referrals/helpers/pglite-db.ts` + `helpers/fixtures.ts` → `src/test/`. Transform: the drizzle schema import must point at the module `../schema.js` (renamed tables) + ledger's `ledgerSchema` where the harness seeds ledger primitives. The harness builds a PGLite-backed drizzle `Database`/`Transaction` and creates tables from the module schema.

- [ ] **Step 2: Verify the harness compiles against the module schema**

Run: `pnpm --filter @platform-modules/affiliate typecheck`
Expected: PASS (no test asserts here yet — the harness is exercised by Tasks 5/9/12). If a renamed table breaks a fixture insert, fix the fixture to the renamed column/table.

- [ ] **Step 3: Commit**

```bash
git add packages/affiliate/src/test/pglite-db.ts packages/affiliate/src/test/fixtures.ts
git commit -m "test(affiliate): port PGLite oracle harness + fixtures onto module schema"
```

---

### Task 5: Port `/fraud` engine — types + registry (EARN fail-closed)

**Wave:** 3
**Blocks:** Task 6
**Blocked by:** Task 3, Task 4

**Files:**
- Create: `packages/affiliate/src/fraud/types.ts`, `src/fraud/registry.ts`, `src/fraud/index.ts`, `src/fraud/decision-points.int.test.ts`, `src/fraud/fraud-admin.int.test.ts`

- [ ] **Step 1: Port the failing oracle tests first**

Port donor `tests/integration/referrals/decision-points.int.test.ts` + `fraud-admin.int.test.ts` → `src/fraud/*.int.test.ts`. Transform import paths to `./registry.js`, `./types.js`, `../test/pglite-db.js`. These assert `runFraudPipeline` over `CLICK/SIGNUP/EARN/WITHDRAW`, including **EARN fail-closed** (an adapter that throws yields a HOLD, never a silent allow).

- [ ] **Step 2: Run to verify they fail**

Run: `pnpm --filter @platform-modules/affiliate test src/fraud/decision-points.int.test.ts`
Expected: FAIL (`runFraudPipeline` not exported).

- [ ] **Step 3: Port types + registry**

Port donor `fraud/types.ts` verbatim (`DecisionPoint = 'CLICK'|'SIGNUP'|'EARN'|'WITHDRAW'` stored lowercase; `FraudAdapter<Ctx>{ key; points; evaluate }`; `ClickCtx`/`SignupCtx`/`EarnCtx`/`WithdrawCtx`). Port `fraud/registry.ts`: `runFraudPipeline<Ctx>(RunPipelineInput)` (parallel enabled adapters, **EARN fail-closed → hold on adapter failure**, returns max-severity action), `registerAdapter`, `getAdaptersForPoint`. Transform: `RunPipelineInput.db` typed as `@platform-modules/db` `Transaction`/`Database`; schema imports → `../schema.js`; throw `FraudHoldError` from `../errors.js` where the donor produced its hold sentinel (preserve fail-closed semantics exactly — R4). Re-export from `src/fraud/index.ts`.

- [ ] **Step 4: Run to verify they pass**

Run: `pnpm --filter @platform-modules/affiliate test src/fraud/decision-points.int.test.ts src/fraud/fraud-admin.int.test.ts`
Expected: PASS, including the EARN-fail-closed case.

- [ ] **Step 5: Commit**

```bash
git add packages/affiliate/src/fraud/types.ts packages/affiliate/src/fraud/registry.ts packages/affiliate/src/fraud/index.ts packages/affiliate/src/fraud/decision-points.int.test.ts packages/affiliate/src/fraud/fraud-admin.int.test.ts
git commit -m "feat(affiliate): port /fraud engine (types+registry); EARN fail-closed preserved"
```

---

### Task 6: Port the 13 fraud adapters

**Wave:** 3
**Blocks:** Task 16
**Blocked by:** Task 5

**Files:**
- Create: `packages/affiliate/src/fraud/adapters/*.ts` (13) + barrel; `src/fraud/adapters-db.int.test.ts`

- [ ] **Step 1: Port the failing adapters oracle**

Port donor `tests/integration/referrals/adapters-db.int.test.ts` → `src/fraud/adapters-db.int.test.ts` (import paths → module). It exercises every adapter against PGLite.

- [ ] **Step 2: Run to verify it fails**

Run: `pnpm --filter @platform-modules/affiliate test src/fraud/adapters-db.int.test.ts`
Expected: FAIL (adapters not registered).

- [ ] **Step 3: Port all 13 adapters verbatim**

Port each donor `fraud/adapters/<name>.ts` → `src/fraud/adapters/<name>.ts` (imports only; logic verbatim — R4). Checklist (must port ALL 13, no subset):
- [ ] `ip-reputation` · [ ] `ip-cluster` · [ ] `device-fingerprint` · [ ] `email-canonical` · [ ] `email-disposable` · [ ] `email-catchall-domain` · [ ] `referral-graph` · [ ] `payment-instrument` · [ ] `phone-linetype` · [ ] `phone-required-earn` · [ ] `bot-signup` · [ ] `velocity-conversion` · [ ] `identity-ring`

Register them in the same place/order the donor registers (preserve `points` + `key` + severity). Re-export the registration from `src/fraud/index.ts`.

- [ ] **Step 4: Run to verify it passes**

Run: `pnpm --filter @platform-modules/affiliate test src/fraud/adapters-db.int.test.ts`
Expected: PASS (all 13 adapters present).

- [ ] **Step 5: Build the `./fraud` subpath**

Run: `pnpm --filter @platform-modules/affiliate build`
Expected: `dist/fraud/index.js` + `.d.ts` produced.

- [ ] **Step 6: Commit**

```bash
git add packages/affiliate/src/fraud/adapters packages/affiliate/src/fraud/index.ts packages/affiliate/src/fraud/adapters-db.int.test.ts
git commit -m "feat(affiliate): port 13 fraud adapters verbatim + adapters-db oracle green"
```

---

### Task 7: Port attribution + settings (verbatim)

**Wave:** 4
**Blocks:** Task 10
**Blocked by:** Task 2

**Files:**
- Create: `packages/affiliate/src/attribution.ts`, `src/settings.ts`, `src/attribution.int.test.ts`

- [ ] **Step 1: Port the failing oracle**

Port donor `tests/integration/referrals/last-click-earn.int.test.ts` (attribution portion) → `src/attribution.int.test.ts` (import paths → module). Asserts `resolveRefCode`, `isSelfReferral`, `bindReferralOnSignup` (last-click binding).

- [ ] **Step 2: Run to verify it fails**

Run: `pnpm --filter @platform-modules/affiliate test src/attribution.int.test.ts`
Expected: FAIL (exports missing).

- [ ] **Step 3: Port attribution + settings**

Port donor `attribution.ts` → `src/attribution.ts`: `resolveRefCode(db, code)`, `isSelfReferral(db, {referrerUserId, refereeUserId})`, `bindReferralOnSignup(db, BindReferralInput)` (`BindReferralInput` = `{refereeUserId, linkId, clickedAt?, refereeEmail?, refereePhone?, visitorId?, ipHash?, cfBotScore?}`). Transform: `db` typed as `@platform-modules/db` `Database`/`Querier`; schema import → `./schema.js`. Port donor `settings.ts` → `src/settings.ts` (`ReferralSettings`: `tier1/2/3Pct`, `tier2/3MinSales`, `referralPct`, windows) — **verbatim for now** (W6 moves the tier values to a host policy).

- [ ] **Step 4: Run to verify it passes**

Run: `pnpm --filter @platform-modules/affiliate test src/attribution.int.test.ts`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add packages/affiliate/src/attribution.ts packages/affiliate/src/settings.ts packages/affiliate/src/attribution.int.test.ts
git commit -m "feat(affiliate): port attribution (resolve/self-referral/bind) + settings type"
```

---

### Task 8: Port commission engine (verbatim — pre-seam)

**Wave:** 4
**Blocks:** Task 10, Task 11
**Blocked by:** Task 2

**Files:**
- Create: `packages/affiliate/src/commission.ts`, `src/commission.test.ts`

- [ ] **Step 1: Write the failing parity test (edge cases, not the happy line)**

```ts
// src/commission.test.ts
import { describe, it, expect } from 'vitest'
import { computeAffiliateCommission, computeReferralCommission, computePlatformNet, resolveAffiliateTierPct } from './commission.js'

describe('commission engine — verbatim parity (agorot, bigint-exact)', () => {
  it('caps the affiliate pct at 10% and floors', () => {
    // floor(amt · min(pct,10) / 100)
    expect(computeAffiliateCommission(10_000n, 12)).toBe(1_000n) // capped at 10%
    expect(computeAffiliateCommission(9_999n, 7)).toBe(699n)     // floor(699.93)
  })
  it('computePlatformNet uses feePct=10', () => {
    expect(computePlatformNet(10_000n)).toBe(9_000n)
  })
  // resolveAffiliateTierPct + computeReferralCommission: port the donor's exact
  // guardrails.test.ts cases verbatim (do not paraphrase the numbers).
})
```

> Replace the magnitudes above with the donor's `guardrails.test.ts` exact cases if they differ — those are the parity oracle; never invent commission numbers.

- [ ] **Step 2: Run to verify it fails**

Run: `pnpm --filter @platform-modules/affiliate test src/commission.test.ts`
Expected: FAIL (module not found).

- [ ] **Step 3: Port commission verbatim**

Port donor `commission.ts` → `src/commission.ts`: `resolveAffiliateTierPct(monthlySales, settings)` (tier locked at accrual, 30-day rolling), `computeAffiliateCommission(amountPaidAgorot, pct)` = `floor(amt · min(pct,10) / 100)`, `computeReferralCommission(amountPaidAgorot, settings)`, `computePlatformNet(amountPaidAgorot, feePct=10)`, `isSelfVendorPurchase(...)`. **bigint-exact; preserve the floor + 10% cap exactly — R4.** No seam yet.

- [ ] **Step 4: Port the donor unit oracle**

Also port donor `guardrails.test.ts` (commission cases) → merge into `src/commission.test.ts` verbatim.

- [ ] **Step 5: Run to verify it passes**

Run: `pnpm --filter @platform-modules/affiliate test src/commission.test.ts`
Expected: PASS (bit-identical to donor).

- [ ] **Step 6: Commit**

```bash
git add packages/affiliate/src/commission.ts packages/affiliate/src/commission.test.ts
git commit -m "feat(affiliate): port commission engine verbatim (10% cap + floor, bigint-exact) + guardrails oracle"
```

---

### Task 9: Port accrual + maturity sweeps (idempotent; TWO sweeps; read-only host store)

**Wave:** 5
**Blocks:** Task 10
**Blocked by:** Task 2, Task 4, Task 8

> **Accrual half is BUILT + gated** (commit `ecde900`: `accrueCommission(tx, input)` insert-ledger-first idempotent on `(entryType, sourceType, sourceId)`, writes the affiliate-owned `affiliate_credit_ledger`, updates the wallet `pending` bucket, `computeMatureAt` helper). DO NOT re-port it. The LIVE work below is the **maturity sweeps**, which the original single `matureCredits(tx, now)` got wrong — re-gate found the donor ships TWO independently-scheduled sweeps with host-table coupling. **The seam contract is the spec — read `docs/specs/2026-06-17-affiliate-module-extraction-design.md` §3A "Wave-5 extension" VERBATIM; it is authoritative, this task does not restate the interface.**

**Files:**
- Create: `packages/affiliate/src/maturity-sweep.ts` (the two sweeps + `computeWithdrawableAt` + the `MaturityHostStore` interface), `src/maturity-sweep.int.test.ts`
- Modify: `src/index.ts` (re-export `sweepMaturation`, `sweepWithdrawable`, `computeWithdrawableAt`, `MaturityHostStore`)

- [ ] **Step 1: Port the failing oracle**

Port donor `tests/integration/referrals/maturation-sweep.int.test.ts` + `ledger-clawback.int.test.ts` + donor unit `maturation-logic.test.ts` + `reconcile-wallet.test.ts` → `src/maturity-sweep.int.test.ts`. Provide an in-fixture `MaturityHostStore` impl backed by PGLite host tables (`purchases`/`deals`) that mirrors the donor anchor SQL (deal_type CASE / redeemed_at) — the module under test NEVER touches `purchases`/`deals`. Asserts: `sweepMaturation` promotes `pending→matured` at the hold boundary with clawback netting (source_id pairing); `sweepWithdrawable` flips `matured→withdrawable` and the earned/paid netting excludes quarantined + settled-payout rows; both are idempotent (re-run = no-op).

- [ ] **Step 2: Run to verify it fails**

Run: `pnpm --filter @platform-modules/affiliate test src/maturity-sweep.int.test.ts`
Expected: FAIL (`sweepMaturation`/`sweepWithdrawable` missing).

- [ ] **Step 3: Port the two sweeps per spec §3A Wave-5 extension**

Donor sources (R4 preserve-hardened — copy the netting/state-transition CTEs **byte-faithful, including the audit comment blocks**; do NOT re-derive the SQL): `multideal/apps/web/src/server/referrals/maturation.ts` (`sweepMaturation` Phase-2 promote CTE) + `withdrawable-sweep.ts` (`runWithdrawableSweep` Step-2 earned/paid netting). Build per the spec contract:
- `MaturityHostStore` — **read-only**: `getRedemptionFacts(refs)` (joins host `purchases`/`deals`, maps `deal_type`→normalized `kind`) + `getReferralSettings()`. The store NEVER writes `affiliate_credit_ledger`.
- `computeWithdrawableAt(facts, disputeWindowDays): Date | null` — NEW, in the module (mirrors `computeMatureAt`); the module never sees host `deal_type`.
- `sweepMaturation(tx, host)` → `{ promoted, recomputed, sweptAt }`: NO-OP probe → `host.getRedemptionFacts` → recompute `mature_at` via `computeMatureAt`, **module writes** → Phase-2 promote+clawback-netting CTE (FOR UPDATE SKIP LOCKED; affiliate-owned tables ONLY) — byte-faithful.
- `sweepWithdrawable(tx, host)` → `{ updated }`: `host.getReferralSettings()` → **safe-int bound `disputeWindowDays` IN THE MODULE** (`Number.isInteger`, `0 ≤ dw ≤ 3650` — the §1 hard floor) → `host.getRedemptionFacts` → compute `withdrawable_at` via `computeWithdrawableAt`, **module writes** → Step-2 earned/paid netting CTE (affiliate-owned tables ONLY) — byte-faithful.
- Tx boundary: each sweep's anchor-read → module write → netting MUST share the ONE caller-supplied `tx` (read-after-write on a money path; the `Transaction<S>` seam replaces the donor's neon-http per-statement autocommit). NO combined wrapper export.

- [ ] **Step 4: Run to verify it passes**

Run: `pnpm --filter @platform-modules/affiliate test src/maturity-sweep.int.test.ts`
Expected: PASS (maturity boundary + clawback/earned-paid netting + idempotency correct).

- [ ] **Step 5: Bundle-check (host-coupling floor — module-wide)**

Run: `grep -REn "(FROM|JOIN|INTO|UPDATE)\s+(users|payment_methods|purchases|deals|referral_link_stats_daily)" packages/affiliate/dist/`
Expected: 0 matches (all host-table access routes through `MaturityHostStore`/`FraudHostStore`).

- [ ] **Step 6: Commit**

```bash
git add packages/affiliate/src/maturity-sweep.ts packages/affiliate/src/maturity-sweep.int.test.ts packages/affiliate/src/index.ts
git commit -m "feat(affiliate): port maturity sweeps (sweepMaturation+sweepWithdrawable) read-only host store, byte-faithful netting (R4)"
```

---

### Task 10: Port the EARN handler (compose fraud + commission + accrual)

**Wave:** 5
**Blocks:** Task 11
**Blocked by:** Task 5, Task 7, Task 8, Task 9, Task 3

**Files:**
- Create: `packages/affiliate/src/earn.ts`, `src/earn.int.test.ts`

- [ ] **Step 1: Port the failing oracle**

Port donor `tests/integration/referrals/last-click-earn.int.test.ts` (EARN portion) + donor unit `referral-earn-handler.test.ts` → `src/earn.int.test.ts`. Asserts the EARN flow: fraud EARN gate (**fail-closed**) → commission compute → accrue (idempotent), and that a fraud HOLD blocks accrual.

- [ ] **Step 2: Run to verify it fails**

Run: `pnpm --filter @platform-modules/affiliate test src/earn.int.test.ts`
Expected: FAIL (`processReferralEarn` missing).

- [ ] **Step 3: Port the EARN handler**

Port the donor EARN orchestration from `service.ts` → `src/earn.ts` as `processReferralEarn(tx, input)`. **Fail-closed ordering preserved (R4):** run `runFraudPipeline({ point: 'EARN', … })` FIRST; a HOLD/throw → do NOT accrue (throw `FraudHoldError` or return the hold verdict exactly as the donor does). On pass → `computeAffiliateCommission`/`computeReferralCommission` → `accrueCommission`. For W5 the handler reads commission from the verbatim `commission.ts` (the seam swap is W6). Transform: typed `tx`, schema → `./schema.js`.

- [ ] **Step 4: Run to verify it passes**

Run: `pnpm --filter @platform-modules/affiliate test src/earn.int.test.ts`
Expected: PASS (HOLD blocks accrual; pass accrues idempotently).

- [ ] **Step 5: Commit**

```bash
git add packages/affiliate/src/earn.ts packages/affiliate/src/earn.int.test.ts
git commit -m "feat(affiliate): port EARN handler (fraud-gate-first fail-closed → commission → accrue)"
```

---

### Task 11: Refactor commission to the `CommissionPolicy` seam (the ×1 over-fit guard)

**Wave:** 6
**Blocks:** Task 16
**Blocked by:** Task 8, Task 10

**Files:**
- Modify: `packages/affiliate/src/commission.ts`, `src/earn.ts`, `src/index.ts`
- Create: `apps/consumer/src/affiliate/multidealCommissionPolicy.ts`, `apps/consumer/src/affiliate/multidealCommissionPolicy.test.ts`

- [ ] **Step 1: Write the failing parity test for the host policy**

The multideal tier model, expressed as a **host** `CommissionPolicy`, must produce **byte-identical** output to the verbatim `computeAffiliateCommission`/`resolveAffiliateTierPct` on multideal's fixtures.

```ts
// apps/consumer/src/affiliate/multidealCommissionPolicy.test.ts
import { describe, it, expect } from 'vitest'
import { multidealCommissionPolicy } from './multidealCommissionPolicy.js'
import { computeAffiliateCommission, resolveAffiliateTierPct } from '@platform-modules/affiliate'
import { MULTIDEAL_REFERRAL_SETTINGS } from './multidealCommissionPolicy.js'

describe('multideal CommissionPolicy ≡ verbatim tier engine (swap-survival)', () => {
  it('matches the verbatim commission on representative sales', () => {
    for (const { amountPaidMinor, rollingSalesCount } of [
      { amountPaidMinor: 10_000n, rollingSalesCount: 0 },
      { amountPaidMinor: 9_999n, rollingSalesCount: 200 },
      { amountPaidMinor: 250_000n, rollingSalesCount: 999 },
    ]) {
      const pct = resolveAffiliateTierPct(rollingSalesCount, MULTIDEAL_REFERRAL_SETTINGS)
      const expected = computeAffiliateCommission(amountPaidMinor, pct)
      const got = multidealCommissionPolicy.resolve({
        amountPaidMinor,
        context: { referrerId: 'r', refereeId: 'e', rollingSalesCount },
      })
      expect(got).toBe(expected)
    }
  })
})
```

- [ ] **Step 2: Run to verify it fails**

Run: `pnpm --filter consumer test src/affiliate/multidealCommissionPolicy.test.ts` (or the consumer harness's test command)
Expected: FAIL (policy + `CommissionPolicy` interface missing).

- [ ] **Step 3: Add the `CommissionPolicy` seam to the module**

In `src/commission.ts`, add the frozen contract + the generic compute, keeping the verbatim tier fns (now used by the host policy, re-exported for parity tests):

```ts
export interface CommissionContext {
  referrerId: string
  refereeId: string
  productId?: string
  rollingSalesCount?: number
}
export interface CommissionPolicy {
  resolve(input: { amountPaidMinor: bigint; context: CommissionContext }): bigint
}
export function computeCommission(
  policy: CommissionPolicy,
  input: { amountPaidMinor: bigint; context: CommissionContext },
): bigint {
  return policy.resolve(input)
}
```

Export `CommissionPolicy`, `CommissionContext`, `computeCommission` from `src/index.ts`. Keep `computeAffiliateCommission`/`resolveAffiliateTierPct` exported (parity oracle).

- [ ] **Step 4: Refactor `processReferralEarn` to take a policy**

Change `src/earn.ts` `processReferralEarn` to accept `policy: CommissionPolicy` and call `computeCommission(policy, …)` instead of the inline tier math. Update `src/earn.int.test.ts` to pass a policy built from `MULTIDEAL_REFERRAL_SETTINGS` (the test's own policy impl) — the EARN oracle must stay green (byte-identical accrual).

- [ ] **Step 5: Write the host policy in the consumer harness**

```ts
// apps/consumer/src/affiliate/multidealCommissionPolicy.ts
import type { CommissionPolicy } from '@platform-modules/affiliate'
import { computeAffiliateCommission, resolveAffiliateTierPct, type ReferralSettings } from '@platform-modules/affiliate'

export const MULTIDEAL_REFERRAL_SETTINGS: ReferralSettings = {
  // paste multideal's real tier %s / min-sales / windows verbatim from the donor settings
}
export const multidealCommissionPolicy: CommissionPolicy = {
  resolve({ amountPaidMinor, context }) {
    const pct = resolveAffiliateTierPct(context.rollingSalesCount ?? 0, MULTIDEAL_REFERRAL_SETTINGS)
    return computeAffiliateCommission(amountPaidMinor, pct)
  },
}
```

> The tier model lives **in the host**, not the module — this is ADR-4 (the ×1 over-fit guard). `ReferralSettings` stays exported by the module only as the donor's transitional type; a future cleanup may move it fully host-side.

- [ ] **Step 6: Run both parity tests**

Run: `pnpm --filter consumer test src/affiliate/multidealCommissionPolicy.test.ts && pnpm --filter @platform-modules/affiliate test src/earn.int.test.ts src/commission.test.ts`
Expected: PASS — host policy ≡ verbatim engine; EARN accrual unchanged.

- [ ] **Step 7: Commit**

```bash
git add packages/affiliate/src/commission.ts packages/affiliate/src/earn.ts packages/affiliate/src/index.ts apps/consumer/src/affiliate/multidealCommissionPolicy.ts apps/consumer/src/affiliate/multidealCommissionPolicy.test.ts
git commit -m "feat(affiliate): CommissionPolicy seam; multideal tier model becomes a host policy (parity-proven)"
```

---

### Task 12: Port payout debit/restore (verbatim — pre-seam)

**Wave:** 7
**Blocks:** Task 13
**Blocked by:** Task 2, Task 3, Task 4

**Files:**
- Create: `packages/affiliate/src/payout.ts`, `src/payout.int.test.ts`

- [ ] **Step 1: Port the failing oracle**

Port donor `tests/integration/referrals/ledger-clawback.int.test.ts` (payout portion) + donor unit `clawback-logic.test.ts` → `src/payout.int.test.ts`. Asserts: `debitPayoutInTx` locks + guards `withdrawable ≥ amt AND matured ≥ amt`, throws `InsufficientBalanceError` on either bucket short; `restorePayoutDebitInTx` reverses on failure; idempotency on `idempotencyKey`.

- [ ] **Step 2: Run to verify it fails**

Run: `pnpm --filter @platform-modules/affiliate test src/payout.int.test.ts`
Expected: FAIL (`debitPayoutInTx` missing).

- [ ] **Step 3: Port payout-debit verbatim**

Port donor `payout-debit.ts` → `src/payout.ts`: `debitPayoutInTx(tx, userId, payoutId, amountAgorot) → ledgerEntryId`, `ensurePayoutLedgerDebit`, `verifyPayoutReadyForSettlement`, `restorePayoutDebitInTx`. **Preserve verbatim (R4 — audit-hardened money path):** the `FOR UPDATE` row lock on the affiliate wallet, the **two-bucket guard** (`withdrawableAgorot ≥ amt AND maturedAgorot ≥ amt`), the TOCTOU ordering. Transform ONLY: `tx` typed `@platform-modules/db` `Transaction<S>` (a non-tx `Querier` must be rejected — the seam enforcing itself); `InsufficientBalanceError` from `./errors.js`; schema → `./schema.js` (renamed wallet table). **No `ledger.debitWithRead` yet** (that swap is Task 14).

- [ ] **Step 4: Run to verify it passes**

Run: `pnpm --filter @platform-modules/affiliate test src/payout.int.test.ts`
Expected: PASS — both-bucket guard + restore + idempotency match the donor.

- [ ] **Step 5: Commit**

```bash
git add packages/affiliate/src/payout.ts packages/affiliate/src/payout.int.test.ts
git commit -m "feat(affiliate): port payout debit/restore verbatim (FOR UPDATE two-bucket TOCTOU guard preserved)"
```

---

### Task 13: `PayoutExecutor` seam + `settlePayout` (no provider import)

**Wave:** 7
**Blocks:** Task 14, Task 16
**Blocked by:** Task 12

**Files:**
- Modify: `packages/affiliate/src/payout.ts`, `src/index.ts`
- Create: `src/payout.settle.test.ts`, `tools/affiliate-bundle-check.test.ts` (or extend the existing root bundle-check harness)

- [ ] **Step 1: Write the failing settle test (executor failure → restore)**

```ts
// src/payout.settle.test.ts — uses the PGLite harness
import { describe, it, expect } from 'vitest'
import { settlePayout, type PayoutExecutor } from './payout.js'
// ... build tx + a withdrawable balance via fixtures ...

describe('settlePayout — debit → execute → restore-on-failure', () => {
  it('restores the debit when the executor fails', async () => {
    const failing: PayoutExecutor = { execute: async () => ({ ok: false, code: 'PROVIDER_DOWN', error: 'x' }) }
    // expect: debit applied, executor fails, restorePayoutDebitInTx reverses, balance unchanged net
  })
  it('marks paid + records externalRefs when the executor succeeds', async () => {
    const ok: PayoutExecutor = { execute: async () => ({ ok: true, externalRefs: { transferId: 't', payoutId: 'p' } }) }
    // expect: debit persists, payout row records refs
  })
})
```

- [ ] **Step 2: Run to verify it fails**

Run: `pnpm --filter @platform-modules/affiliate test src/payout.settle.test.ts`
Expected: FAIL (`settlePayout`/`PayoutExecutor` missing).

- [ ] **Step 3: Add the seam + composing function**

```ts
// src/payout.ts (append)
export interface PayoutDestination { accountId: string; [k: string]: string }
export interface PayoutExecutor {
  execute(req: { payoutId: string; amountMinor: bigint; destination: PayoutDestination }):
    Promise<{ ok: true; externalRefs: Record<string, string> } | { ok: false; code: string; error: string }>
}
// settlePayout: debitPayoutInTx → executor.execute → on !ok restorePayoutDebitInTx; idempotent on payoutId
export async function settlePayout(/* tx, executor, req */) { /* compose the ported primitives */ }
```

Export `PayoutExecutor`, `PayoutDestination`, `settlePayout` from `src/index.ts`. The module **never imports `stripe`** — the executor is the host's adapter (multideal's `payments/connect/affiliate-payout.ts` becomes the `PayoutExecutor` impl in Wave 9).

- [ ] **Step 4: Bundle-check — assert the core is provider-SDK-free**

Add an assertion (extend the root `tools/` bundle-check harness — see `docs/.../harness-vehicle-by-surface`) that the built `dist/index.js` + `dist/payout` contain no `stripe` / provider SDK import.

- [ ] **Step 5: Run both**

Run: `pnpm --filter @platform-modules/affiliate test src/payout.settle.test.ts && pnpm --filter @platform-modules/affiliate build && node tools/affiliate-bundle-check.test.ts` (or the repo's bundle-check command)
Expected: PASS — restore-on-failure correct; no SDK in the bundle.

- [ ] **Step 6: Commit**

```bash
git add packages/affiliate/src/payout.ts packages/affiliate/src/index.ts packages/affiliate/src/payout.settle.test.ts tools/affiliate-bundle-check.test.ts
git commit -m "feat(affiliate): PayoutExecutor seam + settlePayout (restore-on-failure); core proven SDK-free"
```

---

### Task 14: (Gated) swap the FOR-UPDATE primitive to `ledger.debitWithRead`

**Wave:** 7
**Blocks:** Task 16
**Blocked by:** Task 13

**Files:**
- Modify: `packages/affiliate/src/payout.ts`

> **Disk truth — `debitWithRead` is NOT a drop-in for the donor debit (read before attempting the swap).** Verified against `packages/ledger/src/{debit-with-read,append}.ts`: `debitWithRead(tx, input, fn)` ALWAYS calls `appendEntry(tx, {key, delta, reason, ref})`, which inserts the signed-delta row into **ledger's OWN `ledger_entries` table** (idempotent on `input.key`), then runs `plan.apply(tx)`. It constrains the tx schema to `S extends Schema & LedgerSchema` and returns `{ inserted: boolean }`. The donor `debitPayoutInTx` instead writes the **affiliate's own** credit-ledger row and returns a `ledgerEntryId` that `affiliatePayouts.ledgerEntryId` (UNIQUE) consumes. So the swap (a) **relocates the idempotent entry** from `affiliate_credit_ledger` to ledger's `ledger_entries`; (b) **changes the return shape** `ledgerEntryId` → `{ inserted }`, breaking the `affiliatePayouts.ledgerEntryId` FK/UNIQUE contract; (c) requires the affiliate embed to **provision `ledgerSchema` tables** (`ledger_entries` + `wallet_balances`) it otherwise would not need. **Realistic expectation: byte-parity will NOT hold → verbatim retention (Task 12) is the EXPECTED R4 outcome.** Task 14 is a *probe* of whether the ledger primitive can replace the donor debit cleanly — NOT the default path. Run it expecting to revert; keep the swap only if every payout oracle stays byte-identical AND the `ledgerEntryId` contract is preserved.

- [ ] **Step 1: (Probe) refactor the debit to consume the ledger primitive**

Rewrite `debitPayoutInTx`'s lock+guard to call `ledger.debitWithRead`, parameterized over the affiliate wallet — the **two-bucket guard lives in the `fn` callback**. Note the **idempotency surface shifts**: the donor keyed idempotency on the affiliate `idempotencyKey` / `(entryType, sourceType, sourceId)`; `debitWithRead` keys it on ledger's `idempotencyKey` (`input.key`) into `ledger_entries` — a different table and a different idempotency surface, a primary reason parity is expected to break. The affiliate's own `InsufficientBalanceError` guard fires INSIDE `fn` (before any ledger subtraction), so ledger's own `InsufficientBalanceError` class is never surfaced — never catch-and-rethrow it.

```ts
import { debitWithRead } from '@platform-modules/ledger'
import { affiliateWalletBalancesTable } from './schema.js'
import { eq } from 'drizzle-orm'
import { InsufficientBalanceError } from './errors.js'

// inside debitPayoutInTx(tx, userId, payoutId, amountAgorot):
await debitWithRead(tx, {
  lock: { table: affiliateWalletBalancesTable, where: eq(affiliateWalletBalancesTable.ownerId, userId) },
  key: payoutId, // idempotency
  delta: -amountAgorot,
  reason: 'affiliate_payout',
  ref: { payoutId, userId },
}, (lockedRows) => {
  const w = lockedRows[0]
  if (!w || w.withdrawable < amountAgorot || w.matured < amountAgorot) {
    throw new InsufficientBalanceError('payout exceeds matured/withdrawable', {
      requiredMinor: amountAgorot, availableMinor: (w?.matured ?? 0n), bucket: 'matured',
    })
  }
  // delta = the SIGNED amount ledger appends to its OWN ledger_entries row (decoupled from the bucket math);
  // apply = the affiliate-wallet mutation: the donor's two-bucket debit on affiliate_wallet_balances.
  return { delta: -amountAgorot, apply: async (t) => { /* decrement matured + withdrawable + balance on affiliate_wallet_balances */ } }
})
```

- [ ] **Step 2: Run the SAME oracle from Task 12 (byte-parity)**

Run: `pnpm --filter @platform-modules/affiliate test src/payout.int.test.ts src/payout.settle.test.ts`
Expected: PASS — identical to the verbatim port (same guard, same restore, same idempotency).

- [ ] **Step 3: GATE — keep the swap ONLY if parity holds**

If any payout oracle assertion regresses (different lock semantics, idempotency, or restore behavior) **OR the `ledgerEntryId` return-shape / `affiliatePayouts.ledgerEntryId` FK contract cannot be preserved** → **revert Task 14** (the expected outcome), keep the Task-12 verbatim debit (R4: never break a money path to consume a sibling). Record the outcome (kept / reverted) in the wave's commit message.

- [ ] **Step 4: Commit**

```bash
git add packages/affiliate/src/payout.ts
git commit -m "revert — verbatim payout debit retained (R4); ledger.debitWithRead swap broke parity/ledgerEntryId contract"   # or, ONLY if parity + FK contract both held: "refactor(affiliate): payout debit via ledger.debitWithRead over affiliate wallet (parity-held)"
```

---

### Task 15: Port `/security` subpath (opt-in, no core coupling)

**Wave:** 8
**Blocks:** Task 16
**Blocked by:** Task 6

**Files:**
- Create: `packages/affiliate/src/security/*.ts` (10) + `src/security/index.ts`, plus any ported security tests

- [ ] **Step 1: Port the 10 security files**

Port donor `server/referrals/security/*.ts` → `src/security/*.ts`: `index`, `cloaking`, `velocity`, `geo`, `brand-keyword`, `coupon-poaching`, `cookie-stuffing`, `fingerprint`, `referer-eval` (+ remaining). Transform: imports only; `db` → param/type. **No core coupling** — `src/index.ts` (core barrel) must NOT import `./security/*`; it is reachable only via the `@platform-modules/affiliate/security` subpath (opt-in).

- [ ] **Step 2: Port any security tests + assert opt-in isolation**

Add a test asserting the core barrel does not pull `/security` (e.g. the bundle-check shows `dist/index.js` has no `security/` import). Port any donor security unit tests → co-located.

- [ ] **Step 3: Run + build the subpath**

Run: `pnpm --filter @platform-modules/affiliate test src/security && pnpm --filter @platform-modules/affiliate build`
Expected: PASS; `dist/security/index.js` produced; core barrel free of security imports.

- [ ] **Step 4: Commit**

```bash
git add packages/affiliate/src/security packages/affiliate/src/index.ts
git commit -m "feat(affiliate): port /security abuse-detection subpath (opt-in, zero core coupling)"
```

---

### Task 16: Migrate multideal onto `@platform-modules/affiliate` (gated embed)

**Wave:** 9
**Blocks:** —
**Blocked by:** Task 11, Task 13, Task 14, Task 15

> **GATE — both must pass before starting this wave:** (1) `advisor()` re-run over the reconciled design + this plan; (2) `security-guard` adversarial pass over `src/payout.ts` + `src/fraud/`. Do not begin the embed until both are green. This wave is driven by the **`platform-embed-module`** skill (Axis A siblings · Axis B db · Axis D provider · the host `CommissionPolicy`). Do the whole embed in a throwaway `git worktree` of multideal — never its main.

- [ ] **Step 1: Run the full module suite as the parity harness**

Run: `pnpm --filter @platform-modules/affiliate test && pnpm --filter @platform-modules/affiliate build && pnpm --filter @platform-modules/affiliate typecheck`
Expected: all green — this IS the swap-survival gate (multideal has no prod env, ADR-6).

- [ ] **Step 2: Invoke `platform-embed-module`** with `@platform-modules/affiliate` → multideal

Vehicle = local `file:` link (nothing published). Axes: A (db+ledger peers), B (schema — **reconcile table names**: multideal already has `referral_links`/`referrals`/`affiliate_payouts`/`credit_ledger`/`wallet_balances`; the module exports `affiliate_credit_ledger`/`affiliate_wallet_balances`. Decide at embed time: alias the module `pgTable` names to multideal's existing names, OR a forward-only rename migration — **floor: never DROP/rewrite an existing column; new tables only or alias**. If unresolvable cleanly → STOP, report, do not force-fit), D (Stripe Connect = the host `PayoutExecutor`), host `CommissionPolicy` = `multidealCommissionPolicy` (Task 11).

- [ ] **Step 3: Wire the host adapters**

- Host `PayoutExecutor` impl wraps multideal's `payments/connect/affiliate-payout.ts:37` `runAffiliatePayout` (Stripe `transfers.create` + `payouts.create`, idempotency-keyed) → `{ ok, externalRefs } | { ok:false, code, error }`.
- Host `CommissionPolicy` = the tier model (Task 11's policy, moved/confirmed in multideal).
- Pass multideal's drizzle `Database`/`Transaction` into the module functions.

- [ ] **Step 4: Delete the host's now-redundant referrals code**

Replace `apps/web/src/server/referrals/{attribution,commission,payout-debit,maturation,fraud/,security/}` call sites with module calls; delete the replaced files. **If a file can't be deleted, the module didn't cover that surface — surface it (WATCH), don't force-fit.**

- [ ] **Step 5: Swap-survival parity + host suite**

Run multideal's existing referrals test suite + a parity assertion (module output ≡ deleted host output on multideal's real fixtures: commission amounts agorot-identical, tier resolution, maturity buckets, fraud verdicts per DecisionPoint, payout restore).
Expected: multideal's suite stays green; parity asserted.

- [ ] **Step 6: Record rollback + commit (in the multideal worktree)**

Rollback = drop the worktree + revert the migration (forward-only; no down-migration if Axis-B tables aliased/unchanged). Commit per the multideal repo's allow-list (name files; never `git add -A`). Write the embed report (`platform-embed-module` output format) to `multideal/docs/embeds/`.

---

## Self-Review

**1. Spec coverage** (design doc → task):
- §2 attribution → Task 7 ✓ · commission engine → Task 8 + seam Task 11 ✓ · accrual/maturity → Task 9 ✓ · payout-debit → Task 12 + seam Task 13 + ledger-swap Task 14 ✓ · fraud (engine+13) → Tasks 5,6 ✓ · /security opt-in → Task 15 ✓ · commission policy values=host → Task 11 ✓ · PayoutExecutor=host adapter → Task 13 + Task 16 ✓ · Axis-B schema → Task 2 ✓ · maturity tables affiliate-owned (the §1 correction) → Task 2 ✓ · analytics/admin/welcome=host → Task 16 Step 4 (not ported) ✓.
- §3 seam contract (`CommissionPolicy`, `PayoutExecutor`, structural guards) → Tasks 11, 13, 3 ✓.
- §5 money-path safety (typed errors+guards, EARN fail-closed, idempotency, TOCTOU, no bundled provider) → Tasks 3, 5, 9, 10, 12, 13 ✓.
- §6 testing (port oracle, one-test-per-export, swap-parity, security-guard pre-swap) → Task 4 + per-wave oracles + Task 16 gate ✓.
- §7 Approach A staging (fraud → core → seam → payout → security → migrate) → Wave order ✓.

**2. Placeholder scan:** code steps reference donor `file:line` + named transforms, or paste net-new contract TS. Commission magnitudes flagged "replace with donor `guardrails.test.ts` exact cases" (Task 8 Step 1) — the donor test is the authority; the implementer must read it, not invent numbers. No "TODO/TBD/implement later".

**3. Type consistency:** `CommissionPolicy`/`CommissionContext`/`computeCommission` (Task 11) used consistently in Task 11 Step 5. `PayoutExecutor`/`PayoutDestination`/`settlePayout` (Task 13) used in Tasks 13–16. `InsufficientBalanceError`/`isInsufficientBalanceError`, `FraudHoldError`/`isFraudHoldError` (Task 3) used in Tasks 5, 10, 12, 14. `affiliateWalletBalancesTable`/`affiliateCreditLedgerTable` (Task 2) used in Tasks 9, 12, 14. `processReferralEarn` (Task 10) refactored consistently in Task 11.

**4. Wave plan check:** every task has Wave/Blocks/Blocked-by. W2 pair (2,3) = disjoint files (`schema.ts` vs `errors.ts`) ✓. W4 pair (7,8) = disjoint (`attribution.ts`+`settings.ts` vs `commission.ts`) ✓. All money/fraud waves serial. Task B-uses-A ordering: 5→6, 9→10, 11 after 8+10, 12→13→14, 16 after 11/13/14/15 — all later-wave ✓. No same-wave file overlap.

**Open implementer note (spec-first):** the fraud `persistEvents` table (Task 2 Step 1 gate) and the Wave-9 table-name reconciliation (Task 16 Step 2) are the two points where a donor reality may force a spec amendment — both are wired as explicit STOP-and-kickback steps, not silent fixes.
