Parallel Workflow State Confusion in Approvals and Checkout
Single-threaded clicking rarely breaks workflows. Two tabs do.
An expense report, KYC review, or B2B purchase order often allows "approve," "reject," "edit," and "withdraw" on the same workflowId. If those handlers do not lock on version or status, parallel actions leave the record in a state no one designed.
This is adjacent to classic TOCTOU races, but the interesting part is semantic: approved and cancelled at once, or paid while still editable.
Build a conflict pair
You need two actions that should be mutually exclusive. Examples:
- Approver A hits
POST /workflows/W/approvewhile submitter B hitsPOST /workflows/W/withdraw - Tab 1 confirms checkout while tab 2 empties the cart or changes the ship-to
- Support "refund" races customer "capture" on the same payment intent
Capture both requests. Fire them in parallel with Turbo Intruder, or simply two Repeater send-groups timed close together. Repeat ten to twenty times; flaky wins still count if you can show a final state that violates the rules.
Watch the database-facing API afterward. Does the object show status=approved with withdrawnAt set? Does fulfillment start while the UI shows cancelled? That inconsistency is the finding.
Async workers make this messier. The HTTP handlers may both return 200 while a queue consumer applies events out of order. Poll the object for thirty to sixty seconds. If you only snapshot immediately, you miss the broken settle state.
Version tokens people ignore
Look for If-Match, rowVersion, or updatedAt fields returned on GET and ignored on POST. If the client never sends them, try adding them yourself—and also try omitting them when the UI always includes them. Either direction can expose missing optimistic locking.
I've watched an admin console send version: 3 while a mobile app sent no version at all. The mobile path won every conflict. Document which client skipped the check.
Webhooks are another writer. Replay a delayed payment_intent.succeeded while you cancel in the app. Provider retries plus user cancels are a natural parallel pair you can stage with test clocks or delayed forwarding in a lab.
Proof without wrecking production
Use tickets or orders created by accounts you control. Prefer states that do not email real customers or move warehouse stock. A workflow that ends approved with empty line items, or cancelled yet still triggers a payout to your own test beneficiary, is enough.
Do not spam parallel requests at shared inventory for popular SKUs during a live sale. Programs hate collateral damage more than they love race writeups.
Record wall-clock timestamps on both requests and the final GET. Triage sometimes claims "eventual consistency." Your job is to show a stable illegal combination that remains after refresh—not a one-frame glitch in a spinner.
Fix talk
Engineers need a single writer path: database transaction with SELECT … FOR UPDATE, status transitions validated inside the lock, and idempotency keys on approve/pay handlers. "We check status in application code" without a lock is how you got here.
Idempotency keys stop double-clicks from one actor. They do not serialize two different verbs from two actors unless both keys share a lock on the same row.
Race reports land better when you show the illegal final state, not only "two 200 OK responses."