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.
9.5 KiB
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:
- A
CardAcquirer::confirm_after_challengemethod (defaultUnsupportedMethodso old gateways stay correct). - A
RailAdapter::resumemethod (default soft-failure so adapters without a challenge flow stay correct). - An
Orchestrator::resume(intent, rail, driver, psp_payment_id)entry point that reclassifies the resumed outcome and updates the idempotency cache. - An HTTP
POST /v1/intents/resumeendpoint. DeterministicCardAcquirersupport 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
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_challengedefaults toErr(UnsupportedMethod). Existing PSP drivers compile unchanged; calling resume against them surfaces a clean error.RailAdapter::resumedefaults toSoftFailure { 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/intentswith the same idempotency key (after resume completed) returns the cached resumed outcome, not the originalRequiresCustomerAction. - A retry of
POST /v1/intents/resumewith 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
PendingIntentstore 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_idthat'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 aSoftFailure. 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_numberparameter is already onRailAdapter::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.