Transaction Science open standards monorepo. Each subdirectory is a self-contained reference protocol with its own README and licence. - openpay/ Apache-2.0 payment-acceptance stack (Rust, 26 crates) - smart-byte/ CC-BY-4.0 content-addressed value carrier (spec) - eoc/ CC-BY-4.0 energy-optimised compute (spec) The wire format and the right to fork are public. The optional hosted services are the offer; the protocols are not.
216 lines
9.5 KiB
Markdown
216 lines
9.5 KiB
Markdown
# Phase 30 — 3DS / SCA resume primitive
|
||
|
||
**Status**: Draft v0.30
|
||
**Date**: 2026-05-22
|
||
|
||
## Why
|
||
|
||
Until Phase 30, when a card auth returned
|
||
`AttemptOutcome::RequiresAction { url }`, the flow stopped. The HTTP
|
||
intent endpoint returned the URL and the `psp_payment_id`, but
|
||
there was no way to *resume* the intent once the customer
|
||
completed the 3DS challenge. That made the card rail
|
||
half-functional for any SCA-regulated region: Europe (PSD2),
|
||
India, parts of LATAM.
|
||
|
||
Phase 30 closes that loop. It adds:
|
||
|
||
1. A `CardAcquirer::confirm_after_challenge` method (default
|
||
`UnsupportedMethod` so old gateways stay correct).
|
||
2. A `RailAdapter::resume` method (default soft-failure so
|
||
adapters without a challenge flow stay correct).
|
||
3. An `Orchestrator::resume(intent, rail, driver, psp_payment_id)`
|
||
entry point that reclassifies the resumed outcome and updates
|
||
the idempotency cache.
|
||
4. An HTTP `POST /v1/intents/resume` endpoint.
|
||
5. `DeterministicCardAcquirer` support for challenge-then-confirm
|
||
so operators can integration-test the flow without a live PSP.
|
||
|
||
Last phase of the sequenced 28 → 29 → 30 trio. Webhooks, FX,
|
||
3DS — the three highest-impact operator-facing gaps from the
|
||
post-Phase-27 stack — all closed.
|
||
|
||
## What shipped
|
||
|
||
| # | Item | Where |
|
||
|--:|---|---|
|
||
| 1 | `CardAcquirer::confirm_after_challenge(psp_payment_id, idempotency_key)` trait method with default `Err(UnsupportedMethod)` | `op-rails-card/src/acquirer.rs` |
|
||
| 2 | `RailAdapter::resume(intent, psp_payment_id) -> AdapterResult` with default `SoftFailure { code: "resume_not_supported" }` | `op-orchestrator/src/engine.rs` |
|
||
| 3 | `CardAdapter::resume` override — calls `confirm_after_challenge` and reuses `classify_decision` to map back to `AttemptOutcome` | `op-orchestrator/src/adapters/card.rs` |
|
||
| 4 | `Orchestrator::resume(intent, rail, driver, psp_payment_id) -> Result<OrchestrationOutcome>` — looks up the adapter, runs resume, commits the resolved outcome to the idempotency cache | `op-orchestrator/src/engine.rs` |
|
||
| 5 | `DeterministicCardAcquirer::confirm_after_challenge` — synthetic post-challenge settle for integration tests | `op-driver-sdk/src/card.rs` |
|
||
| 6 | `POST /v1/intents/resume` HTTP endpoint — takes the same intent body + `rail` + `driver` + `psp_payment_id` | `op-server/src/handlers/intent.rs`, `routes.rs` |
|
||
| 7 | Tracing span `orchestrator.resume` with `idempotency_key`, `driver`, `rail` fields | `op-orchestrator/src/engine.rs` |
|
||
| 8 | Emitted webhook events: `intent.resumed.approved`, `intent.resumed.declined`, `intent.resumed.requires_action` | `op-server/src/handlers/intent.rs` |
|
||
| 9 | Integration tests: orchestrator-level (challenge then resume → approved; resume against unknown driver → error) + HTTP-level (`POST /v1/intents` → `POST /v1/intents/resume`) | `op-driver-sdk/tests/`, `op-server/tests/` |
|
||
|
||
Workspace at the end of Phase 30:
|
||
|
||
| Check | Result |
|
||
|---|---|
|
||
| `cargo build --workspace --all-targets` | **0 errors, 0 warnings** |
|
||
| `cargo test --workspace` | **1050 passing, 0 failing** (+3 vs Phase 29) |
|
||
| `cargo clippy --workspace --all-targets` | **0 warnings** |
|
||
|
||
## Flow
|
||
|
||
```text
|
||
1. POST /v1/intents
|
||
intent → orchestrator.run() → adapter.attempt()
|
||
→ AuthStatus::RequiresCustomerAction
|
||
─► outcome.terminal_status = RequiresCustomerAction
|
||
─► response carries (psp_payment_id, redirect_url)
|
||
|
||
2. Customer completes the 3DS challenge out-of-band.
|
||
PSP knows. OpenPay doesn't (yet).
|
||
|
||
3. POST /v1/intents/resume
|
||
body: original intent + rail + driver + psp_payment_id
|
||
→ orchestrator.resume()
|
||
→ adapter.resume(intent, psp_payment_id)
|
||
→ acquirer.confirm_after_challenge(psp_payment_id, idem_key)
|
||
→ AuthStatus::Settled (or whatever the PSP reports)
|
||
→ classify_decision → AttemptOutcome::Success
|
||
─► outcome.terminal_status = Approved
|
||
─► idempotency cache committed
|
||
```
|
||
|
||
The resume primitive is **stateless on the server side**: the
|
||
caller hands back the same intent body. We chose this over
|
||
server-side intent persistence because:
|
||
|
||
- The orchestrator already has the idempotency cache; that's the
|
||
source-of-truth for "this intent was previously run."
|
||
- Persisting the intent body would duplicate the idempotency
|
||
store with a new "pending intents" table that has the same
|
||
contents.
|
||
- Operators with stronger "we don't trust the client to remember"
|
||
requirements layer in their own pending-intents store on top —
|
||
small wedge, opinionated, kept out of the reference path.
|
||
|
||
## Backward compatibility
|
||
|
||
Both new trait methods have default implementations:
|
||
|
||
- `CardAcquirer::confirm_after_challenge` defaults to
|
||
`Err(UnsupportedMethod)`. Existing PSP drivers compile
|
||
unchanged; calling resume against them surfaces a clean error.
|
||
- `RailAdapter::resume` defaults to `SoftFailure { code:
|
||
"resume_not_supported" }`. A2A and Crypto adapters that don't
|
||
have a challenge concept inherit this and the resume call
|
||
returns a soft-failure decline rather than panicking.
|
||
|
||
No existing tests broke. The 1047 tests from Phase 29 still
|
||
pass; the 3 new tests are pure additions.
|
||
|
||
## Idempotency semantics
|
||
|
||
`Orchestrator::resume` calls `self.idempotency.commit(&intent_key,
|
||
&outcome)` after a successful resume. That means:
|
||
|
||
- A duplicate `POST /v1/intents` with the same idempotency key
|
||
(after resume completed) returns the cached **resumed**
|
||
outcome, not the original `RequiresCustomerAction`.
|
||
- A retry of `POST /v1/intents/resume` with the same intent +
|
||
psp_payment_id calls the PSP again. PSPs that honor the
|
||
idempotency-key header (Stripe, Adyen, Hyperswitch) return the
|
||
cached decision; PSPs that don't get a new call but should
|
||
return the same status since the challenge is one-shot.
|
||
|
||
The idempotency cache is committed only after a successful
|
||
classify; if the resume itself transport-fails (`SoftFailure`),
|
||
the cache is updated with the soft-failure outcome — re-running
|
||
the same intent body will get the cached soft-fail rather than
|
||
re-attempting authorization. Operators with stricter "always
|
||
re-attempt on soft fail" requirements call `release()` on the
|
||
idempotency store before re-running.
|
||
|
||
## HTTP surface
|
||
|
||
```
|
||
POST /v1/intents/resume
|
||
{
|
||
"idempotency_key": "intent-3ds-1",
|
||
"amount_minor": 1500,
|
||
"currency": "USD",
|
||
"method": { "type": "vault", "token": "tok_v7_3ds" },
|
||
"rail": "card",
|
||
"driver": "hyperswitch",
|
||
"psp_payment_id": "psp_42abcd..."
|
||
}
|
||
```
|
||
|
||
Response shape is identical to `POST /v1/intents` — same
|
||
`IntentResponse` JSON. Operators can write a single handler for
|
||
both endpoints on the consumer side.
|
||
|
||
Emitted events use distinct types (`intent.resumed.approved`
|
||
etc.) so webhook consumers can distinguish first-attempt vs
|
||
post-challenge approval if they care.
|
||
|
||
## Honest concerns (carry-forward)
|
||
|
||
- **No server-side pending-intent store.** The caller carries
|
||
the intent body across the challenge window. Operators wanting
|
||
server-tracked challenges add a thin `PendingIntent` store on
|
||
top (one table keyed on intent idempotency key, expires after
|
||
72 hours).
|
||
- **A2A and Crypto adapters have no resume.** Their challenge
|
||
models are different — A2A "challenges" are typically bank-app
|
||
approvals that complete asynchronously via webhook; crypto
|
||
flows don't have a customer-side challenge in the same sense.
|
||
The default `SoftFailure { code: "resume_not_supported" }`
|
||
surfaces the situation cleanly when operators try.
|
||
- **No challenge-window enforcement.** A `psp_payment_id` that's
|
||
been pending for six weeks will still be accepted by the
|
||
endpoint; the PSP returns the appropriate "expired" error and
|
||
we surface it as a `SoftFailure`. Operators with strict
|
||
windows reject at the HTTP layer (a tower middleware that
|
||
checks elapsed time before forwarding).
|
||
- **No 3DS-on-retry escalator.** Phase 28's option mentioned a
|
||
per-attempt 3DS preference escalator (force 3DS on the second
|
||
attempt of a soft-declined intent). That's a smaller adjacent
|
||
wedge — the `attempt_number` parameter is already on
|
||
`RailAdapter::attempt`, operators can read it in their adapter
|
||
and escalate. Shipping a built-in policy would push opinion
|
||
onto every operator.
|
||
- **No customer-facing UI.** That's frontend. The HTTP endpoints
|
||
support whatever flow the operator builds.
|
||
|
||
## Test totals
|
||
|
||
```
|
||
op-driver-sdk +2 (orchestrator resume happy path, unknown driver error)
|
||
op-server +1 (HTTP create → resume end-to-end)
|
||
----
|
||
+3 net
|
||
```
|
||
|
||
`cargo test --workspace`: **1050 passing, 0 failing.**
|
||
`cargo build --workspace --all-targets`: **0 warnings.**
|
||
`cargo clippy --workspace --all-targets`: **0 warnings.**
|
||
|
||
## Status of the project after Phase 30
|
||
|
||
The 28 → 29 → 30 trio closed the last large operator-facing gaps:
|
||
|
||
| Capability | Phase |
|
||
|---|---|
|
||
| Webhook delivery, end-to-end | 28 |
|
||
| Multi-currency / FX primitives | 29 |
|
||
| 3DS / SCA resume | **30** |
|
||
|
||
Combined with everything before (Phases 11–27), OpenPay is now a
|
||
complete reference payment-acceptance stack with:
|
||
|
||
- Card + A2A + Crypto rails
|
||
- Refunds, disputes, settlement, subscriptions
|
||
- Single-file Minigraf persistence
|
||
- HTTP API server, driver SDK with conformance harness
|
||
- FX conversion, webhook delivery, 3DS resume
|
||
- Bi-temporal time-travel, graph-backed audit reports
|
||
|
||
A vendor can write their own driver, point `op-server` at a
|
||
single `.graph` file, wire their HTTP transport + FX feed, and
|
||
serve a SaaS-shaped payment stack — no external database, no
|
||
queue, no auxiliary service. Margin stays with the vendor.
|