# Affiliate extraction — adversarial review verdict (advisor stand-in, 2026-06-17)

Reviewer: opus, max effort. Targets: `docs/specs/2026-06-17-affiliate-module-extraction-design.md` + `docs/plans/2026-06-17-affiliate-module-extraction.md`. Ledger surface verified on disk (`packages/ledger/src/{debit-with-read,append,errors,types,index}.ts`).

## VERDICT: PASS-WITH-FIXES

Design is sound and doctrine-aligned. Plan is well-staged. But Task 14 (the ledger-primitive swap) rests on assumptions the real `debitWithRead` on disk contradicts — fix the plan text before build so the implementer isn't surprised at the gate.

## ADR verdicts
- ADR-3 (`/security` opt-in subpath): SOUND. Standard affiliate-abuse class, R5 harvest-don't-discard, zero core coupling, isolation test wired (Task 15 Step 2 bundle-check). Not decorative — 10 real donor files. No core zero-dep poison (core barrel must not import `./security/*`, asserted).
- ADR-1 (affiliate-owned maturity tables): SOUND. Verified on disk: ledger's `walletBalances` is generic single-balance (`ownerId/balance/updatedAt`), no maturity buckets, no `(entryType,sourceType,sourceId)` idem. multideal's 5-col maturity wallet is richer → affiliate-owned is correct; pushing maturity into ledger now would freeze a ×1 contract (YAGNI). Convergence>count honored.
- ADR-6 (no-prod swap gate): SOUND-WITH-CAVEAT (escalate phrasing only). PGLite oracle + security-guard is the strongest gate achievable without prod and the risk is user-accepted. Caveat: ledger-swap parity (Task 14) cannot prove the ledger_entries write lands correctly without exercising the real ledger schema in the harness — see BLOCKER 1. The gate is sufficient IF Task 14's parity oracle is upgraded to assert the ledger-entries side, not just the affiliate-wallet side.

## BLOCKERS (fix plan/spec before build)

1. **Task 14 / ADR-1 — `debitWithRead` ALWAYS appends to ledger's `ledgerEntries`, not the affiliate credit-ledger.** Disk: `debitWithRead(tx, input, fn)` runs `appendEntry(tx, {key,delta,reason,ref})` which inserts into ledger's OWN `ledgerEntries` table with `idempotencyKey=input.key`, then runs `plan.apply(tx)`. The donor `debitPayoutInTx` writes the affiliate's OWN ledger row and returns a `ledgerEntryId` (consumed by `affiliatePayouts.ledgerEntryId` UNIQUE). The swap therefore (a) moves the idempotent entry from `affiliate_credit_ledger` to ledger's `ledger_entries`, (b) returns `{inserted:boolean}` not a `ledgerEntryId`, (c) requires the tx schema to be `S extends Schema & LedgerSchema` and the affiliate DB to provision ledger's `ledger_entries`+`wallet_balances`. → Byte-parity will almost certainly NOT hold; the plan currently frames the swap as likely-to-hold. Fix: rewrite Task 14 to state the entry-table relocation + the `ledgerEntryId`→`{inserted}` return-shape change explicitly, set the realistic expectation that the gate likely REVERTS to verbatim (Task 12) under R4, and require the affiliate embed to provision `ledgerSchema` tables IF the swap is kept. Do NOT present Task 14 as the default path.

2. **Task 14 code block — `w.matured ?? 0n` / bucket guard sign + return.** The illustrative code throws `InsufficientBalanceError` with affiliate's rich `detail`, but `debitWithRead`'s `fn` must return a `DebitWithReadPlan { delta: bigint; apply }`. `delta: -amountAgorot` is correct (signed), but the donor debit decrements TWO buckets (matured+withdrawable+balance) — `apply` must mutate the affiliate wallet, and the ledger entry's `delta` is decoupled from the bucket math. Plan must clarify `delta` (the ledger-entry signed amount) vs the bucket decrements done in `apply`, and that idempotency now keys on ledger's `idempotencyKey` (Task 12's idempotency was on affiliate's `idempotencyKey`/`(entryType,sourceType,sourceId)`) — different idempotency surface. This is part of why parity likely breaks.

3. **Spec §3 / Task 3 — affiliate's `InsufficientBalanceError` is a NEW richer error, not ledger's.** Disk: ledger's `InsufficientBalanceError` has a NO-ARG constructor (`super('INSUFFICIENT_BALANCE')`), no `detail`. Plan Task 3 correctly defines affiliate's own with `detail`. GOOD — but the data-flow (spec §4) and Task 14 must NEVER catch ledger's `InsufficientBalanceError` and re-throw as affiliate's; if the swap is kept, the bucket guard runs in `fn` BEFORE `debitWithRead`'s own debit subtraction, so affiliate throws its own first — confirm the plan states affiliate's guard fires inside `fn` (it does, Task 14 Step 1) and ledger's class is never surfaced. Minor: state this explicitly so the implementer doesn't accidentally rely on ledger's class identity.

## IMPROVEMENTS (non-blocking)
- Task 9 Step 3 leaves "use ledger appendEntry only if it preserves parity, else keep donor insert" — same ledgerEntries-relocation issue as Task 14 applies here (accrual writes go to affiliate_credit_ledger, not ledger_entries). Pre-state the expected outcome (likely keep donor insert) so it's not discovered mid-wave. The plan already flags it as "candidate seam, not mandatory" — good, just make the disk reason explicit.
- Wave plan integrity: CLEAN. No same-wave file overlap (W2 schema vs errors; W4 attribution+settings vs commission — disjoint). Topological order honored (5→6, 9→10, 11 after 8+10, 12→13→14, 16 last). Money/fraud waves all serial. Verbatim-then-refactor is the correct risk order for the money path; no wave swaps a money/fraud path on faith (Task 14 gated, Task 12 verbatim first).
- Gate-1 delete-test (whole module): PASSES. Deleting affiliate scatters 13 fraud adapters + fail-closed EARN + TOCTOU payout debit + maturity sweep into every adopter — complexity does NOT relocate cheaply; it's a real L3 module.
- Net-new contract signatures: `CommissionPolicy.resolve→bigint` (minor units, ecosystem-standard, host-policy not donor tiers ✓), `PayoutExecutor.execute→discriminated {ok}|{ok:false,code,error}` (✓ typed, no bare throw, provider-agnostic ✓), structural error guards (✓ brand not instanceof). bigint money discipline held throughout. All match capability-class standard, not just multideal call sites.
- Sibling deps: regular `dependencies` on `workspace:^` ✓, only `drizzle-orm` peer ✓, no `stripe` anywhere ✓, version `0.0.0` ✓.

## QUESTIONS-FOR-USER
None that block — ADR-6 risk already user-accepted and informed. The Task-14 parity expectation is a doctrine call (R4) the plan resolves correctly (revert-if-parity-breaks); no product-intent ambiguity.

## BOTTOM LINE
Design PASS; plan PASS-WITH-FIXES — three plan-text fixes around the ledger-swap (Task 14/9) so the implementer expects the entry-table relocation + return-shape change and treats verbatim-retention (not the swap) as the likely R4 outcome. No structural redesign needed.
