ADR-0005 — Payment-saga R2 invoice archive + webhook-bridge location (spec 05 §6/§8 drifts)
ADR-0005 — Payment-saga R2 invoice archive + webhook-bridge location (spec 05 §6/§8 drifts)
Section titled “ADR-0005 — Payment-saga R2 invoice archive + webhook-bridge location (spec 05 §6/§8 drifts)”Status: Accepted Date: 2026-05-31 Context: Epic #5 (payment saga). Resolves two drifts between spec 05 and the repo before PR-4 (R2 archive) and PR-5 (webhook bridge).
Context
Section titled “Context”Spec 05 says:
- §8 — invoice PDFs archive to an R2 bucket named
nyl-invoices-prod. - §6 — the Stripe webhook bridge is a Hono route
POST /webhooks/stripeonapps/apithat signals the Workflow.
The repo as built drifts from both:
apps/apialready servesPOST /webhooks/stripe— the spec-02 identity webhook (webhook-routes.ts: KYC / setup-intent events). A second handler at the same path is impossible.apps/webhookalready exists and is the worker currently bound toPAYMENT_SAGA(→nowyourlink-api#PaymentSagaWorkflow), an R2 bucketINVOICES=nowyourlink-invoices, and aSAGA_DLQqueue.- The R2 bucket name in the repo is
nowyourlink-invoices(consistent with the existingnowyourlink-creativesconvention), not the spec’snyl-invoices-prod. - Single shared Terraform state (project rule): we do not stand up a second bucket just to match a spec name.
The saga (PaymentSagaWorkflow) executes inside apps/api (exported from nowyourlink-api; ADR-0001 / D40). Its INVOICE_FINALIZE step writes the PDF to R2, so the R2 binding must live on the worker the saga runs in — apps/api, not apps/webhook.
Decision
Section titled “Decision”- R2 bucket =
nowyourlink-invoices(the existing repo name). Do not createnyl-invoices-prod. Spec §8 is updated to the repo name. - The R2
INVOICESbinding lives onapps/api(where the saga runs). TheINVOICE_FINALIZEstep archives the finalized PDF there. - The webhook bridge extends the existing
apps/apiPOST /webhooks/stripehandler (per spec §6’s “on apps/api” logical contract) with a saga-event dispatch branch (invoice.*,payment_intent.*,charge.dispute.*→ signalenv.PAYMENT_SAGA). We do not add a second/webhooks/striperoute, and we do not build a separate bridge onapps/webhook. apps/webhook’s saga bindings are superseded. ItsPAYMENT_SAGA/INVOICES/SAGA_DLQbindings are not used by the saga (which runs inapps/api); they are cleaned up when the webhook bridge lands onapps/api(PR-5), keeping one home for the saga’s R2 + signalling.
Consequences
Section titled “Consequences”- Spec §6/§8 are reconciled to repo reality (bucket name; bridge co-located with the existing identity webhook on
apps/api). - One Terraform state, one invoices bucket; the R2 resource is added to the shared TF root and bound on
apps/api. - PR-4 adds the
INVOICESbinding + theINVOICE_FINALIZE→ R2 archive step (write-oncehead()guard, §147-AO metadata). - Write-once = idempotent skip, not
NonRetryableError. Spec §8’s fallback literally says the guard should “throwNonRetryableErroron collision.” We supersede that: on ahead(key)hit the archiver returns silently. The key embeds Stripe’s globally-unique sequentialinvoice_number, so a collision can only be the same immutable invoice already archived (a Workflow-restart replay where the R2putcommitted but the step’s completion was lost), never a different document — throwing there would permanently fail the saga on a successful prior archive.NonRetryableErroris reserved for the genuinely-terminal R2-write-impossible case. Spec §8 is updated to match. - PR-5 grows the
apps/api/webhooks/stripehandler to route Stripe events to the saga and removesapps/webhook’s now-dead saga bindings. - The §6 “thin webhook bridge Worker” wording is satisfied logically by the
apps/apiroute module (webhooks/stripe), not a distinct worker.