05 — payment-saga design
05 — payment-saga design
Section titled “05 — payment-saga design”Status: Draft Date: 2026-05-23 Owner: backend (Workflows + Stripe specialist) Depends on: 01 foundation-infra, 02 identity-onboarding, 04 auction-engine Feeds: 08 advertiser-dashboard, 10 admin-console, 12 legal-compliance
1. Purpose & scope
Section titled “1. Purpose & scope”Owns the durable post-auction process that turns a winner into a paid, live Spotlight slot. One Cloudflare Workflow per auction, plus a thin Stripe webhook bridge Worker that translates Stripe events into Workflow signals.
In scope: AuctionSaga class (in packages/saga); state machine, retries, sleeps, compensations; invoice-first Stripe sequence (master §10.1); cascade algorithm; webhook bridge at api.nowyourlink.com/webhooks/stripe; invoice PDF archive in R2 (10y, §147 AO); credit-note refund flow; D1 mirror saga_runs for read-side observability.
Out of scope (explicitly): auction state, bid ranking, close trigger (owned by 04; saga receives a ranked snapshot, never writes to the auction DO); ad rendering / serving (07); VIES revalidation (02); the strike column itself (02; this spec only fires the strike API); house-ad selection (10 §9; we call pickHouseAd(auction_id)).
2. Workflow instance keying & lifecycle
Section titled “2. Workflow instance keying & lifecycle”- Instance id =
auction_id(e.g."2026-06-12"). Discoverable:env.SAGA.get("2026-06-12"). - Spawn:
AuctionDO.alarm()at00:00 UTCfiresenv.SAGA.create({ id: auction_id, params: { auction_id, rankedBidders: Bidder[] } }).rankedBiddersis the top-5 snapshot at close, each{ user_id, stripe_customer_id, default_payment_method_id, bid_id, amount_cents, ad_id }. - Complete: after
ACTIVATEsuccess or terminal compensation (HOUSE_AD,REFUND).NonRetryableErroronly for truly terminal failures (all 5 bidders banned, Stripe > 30h down). - Mirror in D1 (
saga_runs, written by bridge +step.dohook):This is the read-side surface for admin + Sentry breadcrumbs; the Workflow itself remains the source of truth.CREATE TABLE saga_runs (instance_id TEXT PRIMARY KEY, -- = auction_idauction_id TEXT NOT NULL,current_step TEXT NOT NULL, -- WIN | INVOICE_CREATE | …winner_user_id TEXT,invoice_id TEXT,status TEXT NOT NULL, -- running | complete | failed | compensatinglast_error TEXT,started_at TEXT NOT NULL,updated_at TEXT NOT NULL,completed_at TEXT);
3. State machine
Section titled “3. State machine”16 vertices. Full transitions:
| State | Entry action | Success → | Failure → | Retries | Timeout |
|---|---|---|---|---|---|
WIN | log, write saga_runs row | INVOICE_CREATE | NonRetryableError | 0 | 30s |
INVOICE_CREATE | customers.update(address refresh) + tax-id reconcile (listTaxIds→diff→createTaxId/deleteTaxId) + invoices.create + invoiceItems.create | INVOICE_FINALIZE | CHARGE_FAILED | 3 exp (30s base) | 5m |
INVOICE_FINALIZE | invoices.finalizeInvoice; store hosted URL; archive PDF to R2 | INVOICE_PAY | CHARGE_FAILED | 5 exp (1m base) | 30m |
INVOICE_PAY | invoices.pay({ off_session:true }) then step.waitForEvent('invoice-paid:${invoice.id}') | ACTIVATE | AWAIT_3DS or CHARGE_FAILED | 3 (Stripe call only) | 30m wait |
AWAIT_3DS | send 3DS email (hosted URL) + step.waitForEvent('3ds:${pi.id}', { timeout:'6 hours' }) | INVOICE_PAY (re-check via invoices.retrieve) | CHARGE_FAILED | 0 | 6h |
CHARGE_FAILED | log; call identity strike API | STRIKE | retry strike API 3x | 3 | 1m |
STRIKE | POST /admin/users/:id/strike (idempotent on saga_instance_id) | CASCADE_TO_RUNNERUP if list has next else HOUSE_AD | NonRetryableError | 3 | 1m |
CASCADE_TO_RUNNERUP | shift ranked list; bump attempt namespace | INVOICE_CREATE | HOUSE_AD if list empty | 0 | 30s |
ACTIVATE | wait until T+24h; RPC auctionDO.activate({ ad_id, user_id }); then re-read creative approval (E6/D27 activation-window gate, #390) — a revoke detected here routes to ACTIVATE_FAILED carrying last_error="creative-revoked:<reason>". The re-read runs AFTER the RPC so a same-day-chargeback SUPERSEDED always wins (no double-claw). | LIVE (or SUPERSEDED if the slot was deactivated) | ACTIVATE_FAILED (DO error / asset gone / creative revoked during the wait) | 5 exp (30s) | 10m |
ACTIVATE_FAILED | log P1 alert | REFUND | NonRetryableError | 0 | 1m |
SUPERSEDED | log; the slot was deactivated by the DisputeSaga (same-day chargeback, #383b). NO refund — the out-of-band credit note already ran | HOUSE_AD | n/a | 0 | 30s |
REFUND | creditNotes.create({ invoice, reason:'duplicate', lines:[...] }) | HOUSE_AD | retry 5x | 5 exp (1m) | 30m |
HOUSE_AD | call pickHouseAd(auction_id) then activate | [*] | NonRetryableError (P0) | 3 | 5m |
LIVE | mark saga_runs.status='complete' | [*] | n/a | n/a | n/a |
CHARGEBACK_RECEIVED | spawn DisputeSaga; this saga terminates | [*] | n/a | 0 | n/a |
Total saga wall-clock budget: ≤ 30 hours from auction.closed to LIVE (24h pre-activate window + up to 6h SCA + retries). NonRetryableError from cloudflare:workflows is used at the ceiling for Stripe outages exceeding the budget.
4. Stripe call sequence (invoice-first, per research §4 + master §10.1)
Section titled “4. Stripe call sequence (invoice-first, per research §4 + master §10.1)”Each bidder attempt (head of ranked list) runs these calls, one per step.do:
customers.update(cust, { address })— refresh the billing address from the currentusersrow (users.countryisNOT NULL, so the address always resolves). Tax-ID reconcile (ownstep.do, after the address update):customers.listTaxIds(cust)→ diff the existing tax IDs againstusers.vat_id→customers.createTaxId(cust, { type, value })for a new/changed VAT-ID andcustomers.deleteTaxId(cust, taxIdId)for a stale or now-absent one (users.vat_idis nullable — aNULLmeans delete any existing tax IDs). The list→diff→create/delete sequence is idempotent and replay-safe: a Workflow restart re-runs it, finds the desired tax-ID already present (or already absent), and no-ops. Note:tax_id_datais a create-only parameter — it is accepted bycustomers.createbut silently ignored bycustomers.update; refreshing a VAT-ID on an existing customer must go through the Tax IDs API (customers.listTaxIds/createTaxId/deleteTaxId), nevercustomers.update. The dedicatedcreateTaxIdendpoint takes{ type, value }directly (notax_id_datawrapper).invoices.create({ customer, collection_method:'charge_automatically', default_payment_method, automatic_tax:{ enabled:true }, auto_advance:false, pending_invoice_items_behavior:'exclude', metadata:{ auction_id, saga_instance_id, attempt }, custom_fields:[{ name:'Leistungsdatum', value:auction_id }] })— draft invoice.invoiceItems.create({ customer, invoice, amount:bid.amount_cents, currency:'eur', description:'Spotlight — '+auction_id }).invoices.finalizeInvoice(invoice.id)— locks §14 UStG numbering; returnshosted_invoice_url+invoice_pdf; Stripe auto-creates the PI. Archive PDF to R2 (§8).invoices.pay(invoice.id, { off_session:true })— confirms PI against saved PM.- Inspect:
paid→ACTIVATE.- PI
requires_action→AWAIT_3DS: emailhosted_invoice_url;step.waitForEvent('3ds:${pi.id}', { timeout:'6 hours' }). On resume:invoices.retrieve(invoice.id)to confirmpaid. Timeout /payment_failed→CHARGE_FAILED. requires_payment_method/card_declined→CHARGE_FAILED(no same-PM retry).
Every step retry starts with invoices.retrieve(invoice.id) and short-circuits if already paid — guards Stripe-commit vs Workflow-replay races (research §3).
Stripe methods used verbatim: customers.update (address only), customers.listTaxIds, customers.createTaxId, customers.deleteTaxId, invoices.create, invoiceItems.create, invoices.finalizeInvoice, invoices.pay, invoices.retrieve, creditNotes.create.
5. Cascade algorithm
Section titled “5. Cascade algorithm”When CHARGE_FAILED fires:
POST /_internal/users/${userId}/strike{ reason:'charge_failed', saga_instance_id, auction_id }. Identity (02) incrementsusers.strikes, setsbanned_atat ≥3, and is idempotent onsaga_instance_id— replay-safe.- Same Workflow instance, no sub-Workflow. Rationale: keeps
instance_id = auction_idinvariant, one Sentry trace per auction, no inter-instance signal plumbing. The “branch” is just a loop iteration with attempt-namespaced step names:invoice-create:attempt-2:user-${uid},invoice-finalize:attempt-2:user-${uid},invoice-pay:attempt-2:user-${uid}. - Shift ranked array; if empty →
HOUSE_AD. - Loop bound: top 5 bidders max (
rankedBidders.length ≤ 5at spawn). Worst-case wall-clock:1 × 6h SCA + 4 × 30min SCA + 5 × pay/finalize ceilings ≈ 10h— comfortably under the 24h pre-activate budget. - Cascaded attempts mint fresh Stripe idempotency keys (Stripe keys expire 24h, research §9). Workflows replay-safety is independent —
step.dois memoised by step name, not Stripe key.
3 strikes → identity sets banned_at (column owned by 02).
6. Webhook bridge Worker
Section titled “6. Webhook bridge Worker”Hono route POST /webhooks/stripe on apps/api (ADR-0001 / D40), handler module apps/api/src/webhooks/stripe.ts, mounted into the worker’s Hono app via app.route('/', stripeWebhookRoute). Uses a Hono raw-body middleware so Stripe.webhooks.constructEventAsync receives the exact body bytes. Never writes D1 directly — it only signals the Workflow.
Verification
Section titled “Verification”Stripe-Signatureheader verified viastripe.webhooks.constructEventAsync(body, sig, env.STRIPE_WEBHOOK_SECRET).- Reject if missing, expired, or signature mismatch → 400. Successful verify → respond
200 OKwithin 2s (Stripe retry SLA).
Dedupe
Section titled “Dedupe”- KV namespace
STRIPE_EVENT_DEDUPE, keyevt_${event.id}, TTL 7 days. await env.STRIPE_EVENT_DEDUPE.get(key)— if present, logduplicate, return 200. Elseput(key, '1', { expirationTtl: 604800 })after successful dispatch.
Event routing
Section titled “Event routing”| Stripe event | Action |
|---|---|
invoice.payment_succeeded | instance.sendEvent({ type:'invoice-paid:'+invoice.id, payload:{ paid:true } }) |
invoice.payment_failed | instance.sendEvent({ type:'invoice-paid:'+invoice.id, payload:{ failed:true } }) |
payment_intent.requires_action | instance.sendEvent({ type:'3ds:'+pi.id, payload:{ status:'requires_action', hosted_url } }) — also enqueues the 3DS email via Resend |
payment_intent.succeeded | instance.sendEvent({ type:'3ds:'+pi.id, payload:{ status:'succeeded' } }) |
payment_intent.payment_failed | instance.sendEvent({ type:'3ds:'+pi.id, payload:{ failed:true } }) |
charge.dispute.created | spawn env.DISPUTE_SAGA.create({ id:'dispute:'+dispute.id, params:{ auction_id, charge_id, amount } }); signal main saga CHARGEBACK_RECEIVED |
invoice.voided | observability + Sentry breadcrumb only |
charge.refunded | observability only |
Instance discovery
Section titled “Instance discovery”const auctionId = event.data.object.metadata?.auction_id; // set in invoices.createif (!auctionId) return ok(); // not oursconst inst = await env.SAGA.get(auctionId);const status = await inst.status();if (status.status === 'complete' || status.status === 'errored') return ok(); // log onlyawait inst.sendEvent({ type, payload });Guard: env.SAGA.get(id) succeeds even for non-existent instances and sendEvent silently drops (research §9). We always check instance.status() first; if the saga is gone we log a webhook.orphan event to Sentry.
7. Idempotency
Section titled “7. Idempotency”Independent idempotency systems, never confused:
auction_closed intake dedupe (#124)
Section titled “auction_closed intake dedupe (#124)”The saga is spawned by an auction_closed signal from AuctionDO. Spec
04 / PR #123 sets the DO’s auction_closed_emitted flag only after
emitAuctionClosed returns, which makes a successful-emit-then-failed-flag
window re-emit the same close on the next alarm-retry / recovery loop. The
saga therefore dedupes auction_closed at its boundary, keyed by the
auction day (= instance_id), mirroring the webhook-replay pattern
below:
- KV namespace
SAGA_DEDUPE, keyauction_closed:${day}, TTL 7 days (claimAuctionClosedDay,packages/saga). A claim is taken before the saga is created; a secondauction_closedfor the samedayfinds the marker present, logsauction_closed.duplicate, and is a no-op — it never advances the saga past its initial state. - Hard backstop: the saga instance id is
auction_id, unique within the Workflow, so at most one instance exists per day even if two claims race the (non-atomic) KV get-then-put window.create({ id })is not idempotent (it throws on a duplicate id), so the transport spawns viacreateBatch([{ id: day, params }]), which is idempotent: a duplicate id within its retention window is silently skipped and excluded from the returned array. - Spawn-seam contract (
consumeAuctionClosed→onFirstClose): the spawn resolves"created"(returned array has the instance) or"already-exists"(empty array —createBatchskipped the duplicate), and rejects only on a transient failure where the instance was not started. The consumer releases the KV claim only on that transient rejection (so a legitimate retry re-runs); on"already-exists"it keeps the marker and reportsduplicate. UsingcreateBatchis what lets the transport honour this without fragile duplicate-id error-string matching — a live saga’s marker is never released, so the day settles into a cleanduplicateno-op instead of thrashing.
This is the hard prerequisite the recovery loop and Epic-4’s
settled → archived step (PR-K) depend on; without it PR #123’s
duplicate-emit trade-off is unsafe.
Workflows replay (intra-instance)
Section titled “Workflows replay (intra-instance)”Handled by the runtime. Every step.do(name, fn) is memoised by (instance_id, step_name). Step names are deterministic and namespaced: ${verb}:attempt-${n}:user-${uid}. Do not rely on Stripe idempotency for replay-safety inside one instance — Workflows handles it.
Stripe idempotency (cross-instance, cross-retry-envelope)
Section titled “Stripe idempotency (cross-instance, cross-retry-envelope)”Every mutating Stripe call sets Idempotency-Key: ${saga_instance_id}:${step_name}:${attempt_number}. Stripe keys expire after 24h so the attempt_number is a Workflow-step retry counter (0..max). For cross-day cascades, the Workflow attempt counter also increments — keys never collide across days because saga_instance_id is auction_id (date).
Webhook dedupe
Section titled “Webhook dedupe”KV evt_${event.id} TTL 7d (covers Stripe’s 3-day retry window with margin).
Identity strike idempotency
Section titled “Identity strike idempotency”The strike API (owned by 02) dedupes on (saga_instance_id, user_id) — re-firing the strike step never double-counts.
Agent-team hint
Section titled “Agent-team hint”Do not use Stripe idempotency keys as the replay key inside step.do. They’re the cross-system dedupe for Stripe’s view. Workflows’ step memoisation is the intra-instance replay key.
8. Invoice PDF retention (R2 + §147 AO)
Section titled “8. Invoice PDF retention (R2 + §147 AO)”- Bucket:
nowyourlink-invoices(single bucket, partitioned by year prefix; bound asINVOICESonapps/apiwhere the saga runs — see ADR-0005, which reconciles this name with the repo and the single-TF-state rule). - Object key:
invoices/${year}/${auction_id}/${invoice_number}.pdf(e.g.invoices/2026/2026-06-12/INV-2026-000123.pdf).invoice_numbercomes from Stripe — sequential, legally significant. - Upload: in
INVOICE_FINALIZEstep, afterinvoices.finalizeInvoicereturnsinvoice_pdfURL, fetch the PDF andR2.put(key, body, { httpMetadata:{ contentType:'application/pdf' }, customMetadata:{ invoice_id, customer_id, auction_id, finalized_at } }). - Retention: 10 years (§147 AO, §14 UStG).
- Immutability (per master §10.3):
- Primary: R2 Object Lock in Compliance mode with 10-year retention. As of May 2026 it is Beta — gate on GA before relying on it alone.
- Fallback while Object Lock is Beta:
- Application-level write-once guard: pre-check
R2.head(key)and never overwrite. The implementation (packages/saga/r2.ts) treats ahead(key)hit as an idempotent no-op skip, not aNonRetryableError— because the key embeds Stripe’s globally-unique sequentialinvoice_number, a collision can only be the same immutable invoice already archived (a Workflow-restart replay), never a different document. ThrowingNonRetryableErrorthere would permanently fail the saga on a successful prior archive.NonRetryableErrorstays reserved for the genuinely-terminal R2-write-impossible case (ADR-0005). - Secondary backup: write the same PDF to an external WORM target — either an encrypted S3-Glacier-equivalent or pin
hosted_invoice_urlin D1 with Stripe as the canonical archive (Stripe retains finalized invoices ≥10y).
- Application-level write-once guard: pre-check
- Bucket lifecycle: no auto-delete; deletion only via admin runbook after 10y + 1 year safety margin.
9. Refund / credit-note flow
Section titled “9. Refund / credit-note flow”Never use refunds.create on the PI directly (loses the legally required §14 UStG correction doc). Saga always issues a credit note. Both flows credit the same invoice line (lines:[{ type:'invoice_line_item', invoice_line_item: line_id, quantity:1 }]) so Stripe auto-reverses VAT (docs.stripe.com/api/credit_notes/create) and emits a correction PDF; they differ only in how the credited money is placed (a credit note on a paid invoice must satisfy Stripe’s sum rule refund_amount + credit_amount + out_of_band_amount = credited total, so exactly one of those carries amount_paid):
- Full reversal — undisputed (
ACTIVATE_FAILED, admin refund):creditNotes.create({ invoice: invoice_id, reason:'duplicate'|'order_change', refund_amount: invoice.amount_paid, lines:[…], metadata:{ auction_id, saga_instance_id, trigger } }).refund_amountcreates a real Stripe Refund on the charge — correct here because the charge is not disputed and Stripe still holds the funds. - Full reversal — chargeback (
DisputeSaga,trigger:'chargeback'):creditNotes.create({ …, reason:'fraudulent', out_of_band_amount: invoice.amount_paid, … })— NOTrefund_amount. Whencharge.dispute.createdfires, the card network has already pulled the funds back and Stripe has withdrawn them from the account balance, so the money is returned outside Stripe. Arefund_amountcredit note here is rejected by Stripe (charge_disputed— a disputed charge cannot be refunded) and would double-refund;out_of_band_amountrecords the out-of-Stripe return so the credit note still produces the §14 correction document without any Refund. (Implemented via thesettlement: 'refund' | 'out_of_band'discriminator on the saga’screateCreditNoteseam — #383.) - Partial (admin-initiated, #529): the admin enters a gross amount (incl. VAT — what the bidder gets back). Because invoices carry
automatic_tax, the credited lineamountis net, so a net-onlyrefund_amountwould breach the sum rule (refund_amount + credit_amount + out_of_band_amount == post_payment_amount) on any taxed invoice. The seam derives the net partial (round(gross × line.amount / invoice.amount_paid)),creditNotes.previews it to read the actualpost_payment_amountStripe will settle, thencreditNotes.create({ ..., refund_amount: post_payment_amount, lines:[{ ..., amount: net_partial }] })so the rule holds by construction on 19% / 7% / 0% invoices. The settled gross may differ from the entered gross by ≤1 cent (two rounding steps). Known limitation: a full refund issued after a partial still credits the originalinvoice.amount_paid, which exceeds the remaining creditable amount → Stripe rejects → the step wedges (not handled). - PDF archived to R2 at
credit-notes/${year}/${auction_id}/${credit_note_number}.pdf.
Manual test-mode verification (the chargeback credit-note contract, #383). The unit suite proves the param shape against an in-memory Stripe; the live Stripe contract (a refund_amount credit note is rejected on a disputed charge) can only be confirmed against the API. Run in test mode (never the live nowyourlink.com account): (1) create + pay a saga-shaped invoice off a charge made with dispute test card 4000000000000259; (2) wait for charge.dispute.created (status:'needs_response'); (3) confirm creditNotes.create({ invoice, refund_amount: amount_paid, … }) is rejected with charge_disputed, and that the same call with out_of_band_amount instead succeeds and emits the correction PDF. This documents the empirical check the fakes cannot perform.
Manual test-mode verification (the partial credit-note contract, #529). The unit suite proves the sum rule against an in-memory Stripe that models automatic_tax (a net-only refund_amount throws); the live contract can only be confirmed against the API. Run in test mode: (1) create + pay a saga-shaped invoice with automatic_tax enabled and a non-zero VAT rate (e.g. 19% domestic, so amount_paid = net + VAT); (2) confirm creditNotes.create({ invoice, refund_amount: net_partial, lines:[{ amount: net_partial }] }) is rejected with an invalid_request_error (sum rule breached — refund_amount net-only ≠ post_payment_amount net+VAT); (3) confirm the creditNotes.preview → creditNotes.create({ refund_amount: post_payment_amount, lines:[{ amount: net_partial }] }) flow succeeds and emits the §14 correction PDF. A passing unit test is not a substitute for this empirical check.
10. Error envelope & observability
Section titled “10. Error envelope & observability”Structured logs
Section titled “Structured logs”Every step.do emits a structured log on entry + exit:
{ "auction_id": "2026-06-12", "instance_id": "2026-06-12", "saga_step": "invoice-finalize:attempt-1:user-abc", "attempt": 1, "outcome": "ok" | "retry" | "fail", "duration_ms": 412, "stripe_idempotency_key": "2026-06-12:invoice-finalize:1", "error_code": "saga.invoice-finalize-timeout" | null}Sampled at 100% (saga events are low-volume; see master §10.2 — saga is in the 100%-logged bucket).
Sentry
Section titled “Sentry”@sentry/cloudflareinapps/api+packages/saga. Eachstep.dowrapped inSentry.startSpan({ name:'saga.'+stepName, op:'workflow.step' }). Breadcrumb per state transition;Sentry.captureExceptionon terminalNonRetryableErrorand cascade exhaustion. Tags:auction_id,instance_id,saga_step,attempt,user_id.
HTTP error envelope (webhook bridge → 4xx)
Section titled “HTTP error envelope (webhook bridge → 4xx)”Per master §4.5:
{ "error": { "code": "saga.webhook-signature-invalid", "message": "Stripe signature invalid", "retryable": false } }Codes used here: saga.webhook-signature-invalid, saga.webhook-orphan, saga.charge-failed, saga.cascade-exhausted, saga.activate-failed, saga.refund-failed, saga.house-ad-fallback.
11. Edge cases
Section titled “11. Edge cases”- Card expired between bid and close —
invoices.payreturnsrequires_payment_method→CHARGE_FAILED→ strike + cascade; bidder gets a re-Setup email (02). - SCA timeout — 6h on first attempt, 30min on cascaded attempts; on timeout →
CHARGE_FAILED. - Runner-up also fails — loop until ranked list empty →
HOUSE_AD; strike per failed attempt. - All 5 bidders fail —
pickHouseAd(auction_id)+ activate; slot still served; P1 alert. - Chargeback, same day, slot not served (#383b) —
DisputeSagareverses (out-of-band credit note) + strikes, thendeactivates the won slot inAuctionDO. The DO sets a supersede marker so the original payment saga’s still-pendingT+24hactivateresolves toSUPERSEDED(skipsLIVE, no second refund) and self-heals toHOUSE_AD— the disputed ad never reachesdaily_spotlight. If the disputed ad was already LIVE (mid-serve), theDisputeSagainstead overwrites thedaily_spotlightrow with a house ad immediately (P1, “mid-slot replacement”). The won slot’s public surface isdaily_spotlight(the DOWON_SLOTrecord is server-only), so “ad pulled within 1 min” (§12.9) is met by the marker (pre-serve: the row is never written) or the immediate overwrite (mid-serve). Runner-up respawn (#421) — when the pull ispre-serveANDnow < T+24h - 1hAND a runner-up exists, theDisputeSagainstead spawns a fresh reclaimAuctionSaga(${auction_id}:reclaim:${dispute_id},excludeUserId= the disputed bidder) on the next ranked bidder, the revenue-optimizing half of §11.5 (the runner-up would have paid). The marker scope is two-phase (#432):deactivateleaves the marker unconditional (it records the disputed bidder only as a pending candidate), and a separatescope-reclaimDO step — run by theDisputeSagaonly after the reclaim spawn commits — promotes it to the per-bidder scope, so only the original saga’sactivateis superseded (it stands down atSUPERSEDEDwith no house ad,respawned: true) while the reclaim saga’s runner-upactivateproceeds toLIVE. The split means a permanent reclaim-spawn failure (e.g. a missingPAYMENT_SAGAbinding) leaves the marker unconditional → the original saga self-heals to a house ad (a served, archivable day) instead of standing down for a reclaim that never arrived. The reclaim itself falls back to a house ad if every runner-up declines, so the slot is always served. When the window has passed, there is no runner-up, or the bidder is unmapped, the house-ad fallback (above) stands. - Chargeback after slot served — credit-note + strike only; no reclaim (T&Cs in
12). The DO is archived (gone), sodeactivatereportsnoneand theDisputeSagarecords no house install. - Stripe outage (persistent 5xx) —
step.doexponential retry to step ceiling; if total wall-clock > 30h beforeACTIVATE,NonRetryableError→HOUSE_AD, P0. - Workflow event latency (master §10.4) —
step.waitForEventp99 ~2s. OnlyAWAIT_3DSand post-invoices.paysettle wait on events; both wall-clock-bounded. event.idreplay > 7d — KV TTL gone, webhook re-arrives; bridge seesinstance.status() === 'complete', logswebhook.orphan, returns 200.- VAT-ID lapsed — the Tax-IDs reconcile deletes the stale ID via
customers.deleteTaxId(andcreateTaxIdifusers.vat_idchanged);tax_id_dataoncustomers.updatewould be ignored. Stripe Tax falls back to 19% DE-USt; bidder notified (12). - Bidder banned mid-cascade — strike #3 sets
banned_at; next iteration’sINVOICE_CREATEre-reads identity, throwsNonRetryableErrorfor that bidder only, loop continues. - Creative revoked DURING the T+24h activation window (#390, post-charge/pre-serve) — moderation revokes the winning ad between charge (at close) and activation (
T+24h). The charge-time E6/D27 gate (spec 04 §10) already passed, so the card is charged but the slot is not yetLIVE.ACTIVATEre-reads approval right after theactivateRPC; on a revoke it routes toACTIVATE_FAILED → REFUND → HOUSE_AD— the full-reversal credit note (settlement: "refund", the charge is undisputed so Stripe still holds the funds) and a house ad, mirroring the money-taken asset-gone path (founder ruling 2026-06-08 option b). Not a cascade — the runner-up was never charged; not activate-anyway — that would serve a revoked creative. TheACTIVATE_FAILEDrow carrieslast_error="creative-revoked:<reason>"(the audit signal distinguishing it from asset-gone). The re-read runs AFTER theactivateRPC so a concurrent same-day chargeback (edge 5,SUPERSEDED) wins first — refunding a slot theDisputeSagaalready reversed out-of-band would double-claw the charge.
12. Acceptance criteria
Section titled “12. Acceptance criteria”- Happy path:
auction.closed→LIVEin < 30min on a no-3DS card; invoice PDF in R2;saga_runs.status='complete'. - SCA: 3DS email within 60s of
requires_action; saga advances within 5s of bidder completing 3DS. - Cascade: top decline → strike → runner-up invoiced within 2min.
- House-ad: 5 consecutive declines → house ad live at T+24h with P1 alert.
- Webhook dedupe: replaying any Stripe webhook within 7d is a no-op (KV verified in integration test).
- Replay safety: forced Workflow restart mid-
INVOICE_FINALIZEdoes not double-finalize (verified viainvoices.retrieve). - Retention: PDF in R2 within 2min of finalize, metadata populated, no lifecycle expiry.
- Refund:
ACTIVATE_FAILEDproduces a credit note with VAT correctly reversed (snapshot test). - Same-day chargeback:
charge.dispute.created→ ad pulled within 1min viaDisputeSaga. - Observability: every saga event visible in Sentry with
auction_id+saga_steptags; D1 mirror within 5s of state transition. - Activation-window revoke (#390, edge 12): a creative revoked during the
T+24hwait →ACTIVATE_FAILED → REFUND → HOUSE_ADwith a full-reversal credit note (settlement: "refund") andsaga_runs.last_error="creative-revoked:<reason>"; the slot serves a house ad, the winner is neverLIVE. An approved creative still activates toLIVE. A concurrent same-day chargeback (SUPERSEDED) wins over the revoke gate, with no second refund. (saga unit tests:#390 — creative REVOKED during the T+24h activation window …,#390 — revoke + same-day chargeback … → SUPERSEDED wins.)
13. Agent-team hints
Section titled “13. Agent-team hints”- Workflows API surface:
step.do,step.sleepUntil,step.waitForEvent,NonRetryableErrorfromcloudflare:workflows;env.SAGA.create,env.SAGA.get,instance.status(),instance.sendEvent. - Do NOT use Stripe idempotency keys as the replay key inside
step.do— Workflows handles that via step-name memoisation. Stripe keys are for cross-instance + Stripe-server-side dedupe only. - Always
invoices.retrieve(id)at the top of anystep.dotouching an existing invoice (guards Workflow-restart vs Stripe-commit races, research §3). - Webhook bridge must NOT write D1. It only signals; the saga writes
saga_runs. - Step names deterministic across replays — no
Date.now()/Math.random(); use the loop attempt counter. - Use
step.sleepUntilforT+24hto avoid wall-clock drift across retries. - No state in closure — flow data via
step.doreturn values; closures are not replay-persistent. - NonRetryableError ceiling: reserved for truly terminal states (all bidders banned, Stripe > 30h down, R2 write impossible).
- Local dev:
stripe listen --forward-to localhost:8788/webhooks/stripe; verify dedupe withstripe events resend evt_.... - Cross-spec coupling: strike API →
02;activate()RPC →04; house-ad picker →10(§9); credit-note PDF acceptance →12. - Webhook routing (D40 / ADR-0001): Hono route with a raw-body middleware applied before signature verification; deduplication via the
SAGA_DEDUPEKV namespace inside the handler.