# S12 race conditions / idempotency — band-1 recall [MEASURED 2026-06-20]

audience: AI coding agents. BLUF. Evidence for promoting `race_conditions` from BLIND-SPOT-CANDIDATE → a MEASURED bench class. Mirrors the S4/S6 pilot doc shape.

## BLUF
- **First class beyond the fable S1–S11 set.** Harvested from a real git fix commit (no synthesis).
- **RED `vuln.ts`: CAUGHT 3/3 right-reason** (critical), bench `recall=1`.
- **GREEN `safe.ts`: discriminator PASS**, bench `recall=0` — the canonical double-spend is NOT re-raised on the fixed file.
- **n=1 point-estimate** (one cell). Recall is a RATE only at n≥3; this is the same bar as S4/S6, honestly labelled.

## Cell provenance (git-fix-pinned, raw git only)
- Repo: `trance-shop-israel`. Fix commit `d38a6b17` — *"fix(payments): atomic PayPal capture claim + guest track download URL"* (+13/-0 on this file, pure-additive guard).
- File: `apps/web/src/pages/api/checkout/capture.ts` (`POST /api/checkout/capture`).
- Reconstruction (CLAUDE.md discipline — `command git --no-pager show`, not RTK-routed):
  - `vuln.ts` = `d38a6b17~1:…/capture.ts` (145 lines, RED — no claim).
  - `safe.ts` = `d38a6b17:…/capture.ts` (158 lines, GREEN — adds the atomic claim).
- Cell: `domains/security/corpus/S12-paypal-capture-double-spend/{canonical.json,vuln.ts,safe.ts}`.

## The defect (single-file, in-file visible)
RED reads `order.status` (`=== "paid"` / `!== "pending"` CHECK at the order fetch), then for PayPal goes straight to:
```ts
const paypal = new PayPalProvider();
const capture = await paypal.capturePayment(payment.providerRef);   // ACT — no atomic claim between check and act
```
Two concurrent `POST /api/checkout/capture` requests (success-page redirect + network retry) both pass the `pending` check and both call `capturePayment` → **double charge / double fulfillment** (CWE-362 TOCTOU).

GREEN inserts a single-winner atomic claim between the check and the capture:
```ts
const captureClaimResult = await db.update(schema.payments)
  .set({ status: "capturing" })
  .where(and(eq(schema.payments.id, payment.id), eq(schema.payments.status, "pending")))
  .returning({ id: schema.payments.id });
if (captureClaimResult.length === 0) return json({ success: true });  // losers return early
```

## Measurement (blind catch-test, k=3 union, flakiness-audit config)
- Detector: band-1 `baseline` (`domains/security/detectors/baseline/baseline.prompt.txt`).
- `gate.py <cell> --k 3 --config-dir /tmp/sg_cfg` (clean unseeded config; creds refreshed; `claude -p --model sonnet --effort medium`). Each roll's cwd is a `/tmp` bundle → project CLAUDE.md / ground truth NOT visible to the detector.
- `canonical_symbol = "double-spend capture"` (distinctive `{double, spend, capture}`). Verified to match ONLY the canonical title among all 12 RED union findings — the sibling races (ticket over-issuance, coupon double-increment, payment-status overwrite) say "issuance"/"increment", never "spend", so they cannot false-credit.

### RED `vuln.ts` — recall 3/3
Top finding, found by **3/3 rolls**, severity critical:
> `[critical] Double-spend: check-then-capture TOCTOU on order status — no atomic state lock before external call`

`bench.py --findings … --require S12-paypal-capture-double-spend` → `recall=1`, `missing_required=[]`, exit 0.

### GREEN `safe.ts` — discriminator PASS, recall 0
No GREEN finding matches `{double, spend, capture}` — the atomic-claim fix removed the double-spend and the gate correctly does not re-raise it. `bench.py --findings …` → `recall=0`.

The fix introduces a **real new TP** (canonical-clean ≠ globally-clean, S2 precedent):
> `[high] Payment permanently stuck in "capturing" — no recovery after Worker eviction or timeout`

This is the orphaned-`capturing`-state risk (capturePayment can throw after the claim), a distinct finding — not the double-spend reason — so it does not block the discriminator.

### no-false-clean honored
The S9 self-deal oracle reported `SILENT but UNRELIABLE` (unresolved `_helpers` imports) on both files — a single-file cell has unresolved cross-file imports, so the oracle declines to call clean. The band-1 recall is on the in-file race, unaffected.

## Honesty / open
- **n=1 point-estimate**, not a RATE. n≥3 rate-promotion is corpus-blocked on CLEAN second/third cells: the one clean candidate is **platform `c3b9c35`** `execute-refund.ts` (+8, `FOR UPDATE` lost-update lock); multideal `b60d81847` (webhook double-fulfillment, 7-file refactor) and trance `36606dfe` (renewal idempotency, mixed-concern HMAC+env+idempotency) are not single-file-clean. Synthesis is FORBIDDEN — promote only on real fix commits.
- Routing: S12 is baseline-floor (no opt-in specialist) → reported BLIND_SPOT by `bench.py --routing` (like S4/S5/S6/S7/S10), not a routing MISS.
- **Threat to validity — target path is in the prompt (SYSTEMIC, not S12):** for a single-file cell (no deps), `gate.py` builds no neutral bundle (`else: module_path = target`) and injects the RAW path into `{{MODULE_PATH}}`, so the prompt sees `…/S12-paypal-capture-double-spend/vuln.ts` — the dir name *and* the `vuln`/`safe` filename. True of EVERY single-file cell (`…sqli…`, `…ssrf…`, `vuln.ts` vs `safe.ts`), not a S12 artifact. **The discriminator CONTROLS for it:** `safe.ts` (literally "safe", SAME `…double-spend` dir) scored recall=0 — a path-primed model would have re-raised the canonical on the identically-named dir — and `vuln.ts` drew 12 spread findings (atob, guest token, ticket soldCount …), not the narrow output of a path-primed run. Mechanical leak, empirically non-determinative; a path-blinding harness is a separate methodology task, not warranted by this evidence.
