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.
411 lines
20 KiB
Markdown
411 lines
20 KiB
Markdown
# Phase 11 — `op-orchestrator` + `kiosk-linux` + e2e harness
|
|
|
|
**Status**: Draft v0.11
|
|
**Date**: 2026-05-17
|
|
|
|
## What shipped
|
|
|
|
Three new artifacts that close the architectural completion of the
|
|
stack:
|
|
|
|
1. **`crates/op-orchestrator/`** — cross-rail orchestrator. Idempotency-
|
|
keyed payment intents, fraud-gated routing, retry/fallback across
|
|
card and A2A rails, circuit breakers per (rail, driver), and a
|
|
pluggable adapter pattern that wraps any
|
|
[`op_rails_card::CardAcquirer`] or [`op_rails_a2a::A2aAcquirer`].
|
|
|
|
2. **`examples/kiosk-linux/`** — reference unattended-checkout
|
|
terminal. Wires Phases 1-11 into a single binary that runs
|
|
end-to-end with mock backends (so it boots without a real
|
|
Hyperswitch / FedNow connection). Five scenarios demonstrate
|
|
happy path, PSP fallback, A2A native, idempotency replay, and
|
|
hard decline.
|
|
|
|
3. **`crates/op-orchestrator/tests/e2e.rs`** — 13 integration tests
|
|
that drive the full layered stack through the orchestrator and
|
|
verify the architectural composition delivers the expected
|
|
behavior. These tests are the proof that Phases 1-10 compose
|
|
correctly.
|
|
|
|
The orchestrator is the **keystone** of the stack. Every layer
|
|
shipped in earlier phases now has at least one orchestrator path
|
|
exercising it end-to-end against mock backends.
|
|
|
|
## Verified ground truth
|
|
|
|
Researched live (May 2026 sources) before implementation:
|
|
|
|
| Claim | Source |
|
|
|---|---|
|
|
| Idempotency keys: UUID v4 / v7, included on every authorize+capture, same key carries across retries AND across rail fallbacks. | Adyen API idempotency docs; Stripe system-design interview prompts; Carbon Canyon postmortem on Gist |
|
|
| 7-day minimum TTL for idempotency records | Adyen API idempotency docs |
|
|
| Mismatched body with same key → 409 IdempotencyMismatch (never resolved by retry) | Adyen docs (HTTP 422/409 with error 704) |
|
|
| State machine: payment is workflow, not record. Valid transitions enforced. | Stripe interview prompt: `requires_payment_method → succeeded` |
|
|
| Retry policy: distinguish idempotent (status check) vs non-idempotent (capture). Exponential backoff with jitter. On timeout, query status before retry. | Industry consensus across all sources |
|
|
| Failover: primary timeout/5xx → secondary PSP with SAME idempotency key. Decline (4xx) is customer-side, no fallback retry. | Adyen docs; Crafting Software orchestration architecture guide |
|
|
| Circuit breaker: 5 consecutive failures → open for 60s cooldown → half-open probe → close on success | Crafting Software orchestration architecture guide |
|
|
| No floats for money (already enforced by `op-core::Money`) | Stripe system-design guidance |
|
|
| ISO 20022 UETR: UUID v4 lowercase hyphenated; same idempotency key should produce same UETR for rail-side idempotency contract | ISO 20022 documentation; `op-rails-a2a` Phase 5 design |
|
|
| `end_to_end_id` capped at 35 chars per ISO 20022 | ISO 20022 documentation |
|
|
|
|
## Architecture
|
|
|
|
```
|
|
crates/op-orchestrator/
|
|
├── Cargo.toml — deps on op-{core,vault,fraud,rails-card,rails-a2a} + serde_json + sha2
|
|
├── src/
|
|
│ ├── lib.rs — module roots; pub use re-exports
|
|
│ ├── error.rs — 9-variant Error enum + #[from] op-vault/op-fraud/op-core
|
|
│ ├── intent.rs — PaymentIntent, RoutingHints, body_signature() for mismatch detection
|
|
│ ├── idempotency.rs — IdempotencyKey, IdempotencyStore trait, InMemoryIdempotencyStore
|
|
│ ├── outcome.rs — OrchestrationOutcome, Attempt, AttemptOutcome, TerminalStatus
|
|
│ ├── router.rs — Router trait + PolicyRouter (method-compat filter, a2a_above threshold, prefer_a2a hint)
|
|
│ ├── circuit_breaker.rs — three-state breaker (Closed/Open/HalfOpen), 5/60s defaults
|
|
│ ├── engine.rs — Orchestrator, OrchestratorConfig, BackoffPolicy, RailAdapter trait, AdapterResult
|
|
│ └── adapters/
|
|
│ ├── mod.rs — re-exports
|
|
│ ├── card.rs — CardAdapter wrapping any CardAcquirer
|
|
│ └── a2a.rs — A2aAdapter + MerchantBankProfile wrapping any A2aAcquirer
|
|
└── tests/
|
|
└── e2e.rs — 13 integration tests
|
|
|
|
examples/kiosk-linux/
|
|
├── Cargo.toml — bin target, deps on op-{core,vault,fraud,rails-card,rails-a2a,orchestrator}
|
|
└── src/main.rs — 5-scenario reference terminal with mock acquirers
|
|
```
|
|
|
|
### Orchestrator flow
|
|
|
|
```text
|
|
┌───────────────────────────────┐
|
|
│ PaymentIntent │
|
|
│ - idempotency_key │
|
|
│ - amount (i64 minor units) │
|
|
│ - method (Vault/Wallet/Emv/ │
|
|
│ A2a/Qr) │
|
|
│ - hints (country, BIN, 3DS, │
|
|
│ prefer_a2a) │
|
|
│ - metadata │
|
|
└──────────────┬────────────────┘
|
|
│
|
|
┌─────────▼─────────┐
|
|
│ 1. Idempotency │
|
|
│ reserve slot │
|
|
│ OR │
|
|
│ return cached │
|
|
│ OR │
|
|
│ return │
|
|
│ Mismatch │
|
|
└─────────┬─────────┘
|
|
│
|
|
┌─────────▼─────────┐ ┌─────────────────┐
|
|
│ 2. Fraud scoring │───►│ Decline /Review │
|
|
│ (op-fraud) │ │ short-circuit │
|
|
└─────────┬─────────┘ └─────────────────┘
|
|
│
|
|
┌─────────▼─────────┐
|
|
│ 3. Router │
|
|
│ chain[0..n] │
|
|
└─────────┬─────────┘
|
|
│
|
|
┌─────────────────────────▼─────────────────────────┐
|
|
│ 4. Attempt loop │
|
|
│ for each (rail, driver) in chain: │
|
|
│ - circuit breaker allow? │
|
|
│ - find adapter │
|
|
│ - adapter.attempt(intent, n) │
|
|
│ - classify outcome: │
|
|
│ Success → terminal Approved │
|
|
│ RequiresAction → terminal Pending │
|
|
│ HardDecline → terminal Declined │
|
|
│ (no rail fallback) │
|
|
│ SoftFailure → record breaker failure, │
|
|
│ continue chain │
|
|
│ if no progress: AllRailsExhausted │
|
|
│ if every breaker open: AllCircuitsOpen │
|
|
└───────────────────────────────────────────────────┘
|
|
│
|
|
┌─────────▼─────────┐
|
|
│ 5. Commit cached │
|
|
│ outcome (or │
|
|
│ release slot │
|
|
│ on Err) │
|
|
└───────────────────┘
|
|
│
|
|
▼
|
|
┌───────────────────────────────┐
|
|
│ OrchestrationOutcome │
|
|
│ - terminal_status │
|
|
│ - attempts (per-rail trail) │
|
|
│ - rail_used │
|
|
│ - psp_payment_id (card) │
|
|
│ - uetr (A2A) │
|
|
└───────────────────────────────┘
|
|
```
|
|
|
|
## Key design decisions
|
|
|
|
### 1. Adapter-per-driver, not adapter-per-rail
|
|
|
|
The card and A2A rail traits have very different request shapes:
|
|
`AuthRequest` carries 3DS preferences and PSP metadata,
|
|
`CreditTransferReq` carries SEPA/FedNow agent identifiers, debtor/
|
|
creditor account numbers, names, remittance. Baking all those
|
|
fields into the generic `PaymentIntent` would force every caller
|
|
to populate A2A-specific data even for card payments.
|
|
|
|
Instead, the orchestrator delegates intent-to-rail-request
|
|
translation to a per-driver `RailAdapter`. The orchestrator owns a
|
|
`HashMap<(RailKind, String), Arc<dyn RailAdapter>>` and dispatches
|
|
by name. Operators write more adapters as new rails come online;
|
|
the engine never needs changes.
|
|
|
|
### 2. Merchant-side data lives in the adapter
|
|
|
|
For A2A, the merchant's creditor agent + creditor account + creditor
|
|
name is invariant across every customer interaction. We hold it in
|
|
the `A2aAdapter` itself via `MerchantBankProfile`, fixed at
|
|
construction time. The customer-side debtor identifier comes from
|
|
the `PaymentMethod::A2a(A2aKey)` variant.
|
|
|
|
Operators serving multiple merchants register one `A2aAdapter` per
|
|
merchant tenancy.
|
|
|
|
### 3. Deterministic UETR derivation
|
|
|
|
ISO 20022 mandates a UUID v4 UETR. The intent's idempotency key is
|
|
the natural deduplication anchor — but the merchant calling the
|
|
orchestrator doesn't supply a UETR directly.
|
|
|
|
The A2A adapter **derives** the UETR deterministically from the
|
|
idempotency key: SHA-256 of the key, first 16 bytes reformatted
|
|
into UUID v4 canonical layout (version nibble = 4, variant bits =
|
|
10). The resulting UETR:
|
|
|
|
- Looks like a v4 UUID to any rail validator.
|
|
- Stays constant across retries with the same idempotency key →
|
|
rail-side idempotency contract preserved.
|
|
- Differs for different idempotency keys (collision space:
|
|
2^122 effective).
|
|
|
|
Verified by `e2e_a2a_uetr_is_deterministic_across_retries`: two
|
|
separate `Orchestrator` instances (simulating a process restart)
|
|
processing the same intent submit identical UETRs to the rail.
|
|
|
|
### 4. Oracle discipline preserved through wrap
|
|
|
|
The orchestrator's `Error::Vault(#[from] op_vault::Error)` wraps
|
|
the inner vault error verbatim. Phase 7's oracle-discipline
|
|
collapse (`NotFound | AuthFailed | InvalidToken → VaultLookupFailed`
|
|
in the platform bridges) is a property of the *bridge* layer
|
|
(Phases 8/9/10), not of the orchestrator. The orchestrator
|
|
exposes the rich inner variant so server-side telemetry can
|
|
distinguish them; the platform bridge collapses them on the way
|
|
out. Verified by `e2e_oracle_discipline_preserved_through_orchestrator`.
|
|
|
|
### 5. Retry/fallback classification
|
|
|
|
Industry consensus distinguishes:
|
|
|
|
| Source signal | Mapped to | Action |
|
|
|---|---|---|
|
|
| `AuthStatus::Approved/Settled/AuthorizedAwaitingCapture` | `Success` | Stop, return Approved |
|
|
| `AuthStatus::RequiresCustomerAction` | `RequiresAction { url }` | Stop, return Pending (no fallback) |
|
|
| `AuthStatus::HardDecline/Fraud` | `HardDecline { code }` | Stop, return Declined (no fallback — customer-side problem) |
|
|
| `AuthStatus::SoftDecline/Transient/RequiresMerchantAction` | `SoftFailure { code }` | Record breaker failure, try next chain entry |
|
|
| `A2aStatus::Settled/Accepted/InProgress` | `Success` | Stop |
|
|
| `A2aStatus::Rejected` | `HardDecline { code: reason_code }` | Stop |
|
|
| `A2aStatus::Pending/Transient/OperationalError` | `SoftFailure` | Continue |
|
|
| Transport error (`Transport`, `PspRejected{5xx}`) | `SoftFailure { code }` | Continue |
|
|
| `UnsupportedMethod` | `SoftFailure` | Continue (router shouldn't have picked it; defensive) |
|
|
|
|
### 6. Circuit breaker is deterministic
|
|
|
|
The breaker takes a `now: u64` parameter rather than calling
|
|
`SystemTime::now()` itself. Tests inject a fixed clock via
|
|
`Orchestrator::with_clock(|| 1000)`. Avoids flaky tests around
|
|
cooldown windows and removes a hidden dependency on `std::time`
|
|
for environments that mock time externally.
|
|
|
|
### 7. Idempotency lifecycle: reserve / commit / release
|
|
|
|
```
|
|
┌─────────────┐
|
|
│ reserve │ first call
|
|
│ (in-flight)│
|
|
└──────┬──────┘
|
|
│
|
|
┌────────┴────────┐
|
|
ok │ │ err
|
|
▼ ▼
|
|
┌─────────┐ ┌─────────┐
|
|
│ commit │ │ release │
|
|
│ (cached)│ │ (slot │
|
|
│ │ │ freed) │
|
|
└─────────┘ └─────────┘
|
|
│ │
|
|
future │ future │
|
|
replay │ retry │
|
|
▼ ▼
|
|
returns cached gets fresh
|
|
outcome reservation
|
|
```
|
|
|
|
**Critical invariant**: `release` MUST NOT remove a *committed*
|
|
record. Otherwise a slow retry would re-execute the payment.
|
|
Unit-tested explicitly in
|
|
`idempotency.rs::release_preserves_committed_record`.
|
|
|
|
## Test count
|
|
|
|
Phase 11 contribution: **90 tests** (77 unit + 13 integration).
|
|
|
|
| Module | Unit | Integration |
|
|
|---|---|---|
|
|
| `intent.rs` | 5 | |
|
|
| `idempotency.rs` | 9 | |
|
|
| `outcome.rs` | 4 | |
|
|
| `router.rs` | 10 | |
|
|
| `circuit_breaker.rs` | 9 | |
|
|
| `engine.rs` | 10 | |
|
|
| `adapters/card.rs` | 16 | |
|
|
| `adapters/a2a.rs` | 14 | |
|
|
| `tests/e2e.rs` | | 13 |
|
|
| **Phase 11 total** | **77** | **13** |
|
|
|
|
Each e2e test exercises a specific architectural invariant:
|
|
|
|
1. `e2e_happy_path_card_approves` — basic flow works
|
|
2. `e2e_card_psp_fallback_on_transient` — within-rail driver fallback
|
|
3. `e2e_idempotency_replay_returns_cached_outcome_without_rail_call`
|
|
— the no-double-charge guarantee (rail called ONCE on replay)
|
|
4. `e2e_idempotency_mismatch_rejects_amount_change` — body-signature
|
|
mismatch detection
|
|
5. `e2e_hard_decline_does_not_fall_back` — backup driver never
|
|
called on customer-side decline
|
|
6. `e2e_three_ds_challenge_surfaces_redirect_url` — terminal-pending
|
|
state with redirect_url propagation
|
|
7. `e2e_circuit_breaker_trips_then_short_circuits` — N failures
|
|
open the breaker; subsequent calls return AllCircuitsOpen
|
|
8. `e2e_fraud_decline_short_circuits_before_rail` — rail NOT called
|
|
when fraud declines (verified with call counter)
|
|
9. `e2e_a2a_uetr_is_deterministic_across_retries` — same key
|
|
produces same UETR even across orchestrator instances
|
|
10. `e2e_a2a_native_round_trip` — A2A flow produces UETR but not
|
|
psp_payment_id
|
|
11. `e2e_vault_tokenized_pan_flows_through_orchestrator` — real
|
|
Phase 7 vault tokenizing a real PAN, used as PaymentMethod
|
|
12. `e2e_all_soft_failures_exhausts_chain` — every driver soft-fails
|
|
→ AllRailsExhausted
|
|
13. `e2e_oracle_discipline_preserved_through_orchestrator` —
|
|
op_vault::Error variants survive #[from] wrap
|
|
|
|
## Cumulative state
|
|
|
|
| Phase | Tests | LOC |
|
|
|---|---|---|
|
|
| 1 op-core | 19 | ~600 |
|
|
| 2 op-iso20022 | 43 | ~1,400 |
|
|
| 3 op-emv | 50 | ~1,800 |
|
|
| 4 op-rails-card | 46 | ~2,100 |
|
|
| 5 op-rails-a2a | 73 | ~3,200 |
|
|
| 6 op-fraud | 65 | ~2,400 |
|
|
| 7 op-vault | 51 | ~2,600 |
|
|
| 8 op-ffi-swift | 44 | ~2,700 |
|
|
| 9 op-ffi-jni | 69 | ~2,950 |
|
|
| 10 op-wasm | 71 | ~2,200 |
|
|
| **11 op-orchestrator + kiosk + e2e** | **90 (77 + 13)** | **~4,150** |
|
|
| **Total** | **~621** | **~26,100** |
|
|
|
|
## Bugs caught during this phase
|
|
|
|
1. **`Money` API misused.** First-draft `intent.rs` called
|
|
`Money::from_minor_units(amount, currency).unwrap()` and
|
|
accessed `amount.minor_units()` / `amount.currency()` as
|
|
methods. Real API is `Money::from_minor(amount, currency)`
|
|
(infallible) with `minor_units: i64` and `currency: Currency`
|
|
as **public fields**. Caught by reading op-core source
|
|
directly.
|
|
|
|
2. **`A2aKey` has no `opaque_digest` method.** I assumed an API
|
|
that didn't exist. Real shape is an enum `{Upi(String),
|
|
Pix(String), Iban(String), UsAch{routing, account}}`. Fixed
|
|
by writing a local `a2a_key_signature(&A2aKey) -> String`
|
|
helper in `intent.rs` that projects each variant to a stable
|
|
string.
|
|
|
|
3. **`op_fraud` API surface wrong on first attempt.** Initial
|
|
engine.rs imported `Decision` (doesn't exist — it's
|
|
`FraudDecision`) and `PaymentFeatures::from_money` (doesn't
|
|
exist — features are `[f32; 32]` arrays built via
|
|
`extract_features(&PaymentDescriptor, &ScoringContext)`).
|
|
Threshold application is `Thresholds::decide(score)`. Fixed
|
|
by reading op-fraud source.
|
|
|
|
4. **`PaymentDescriptor` not re-exported at crate root.** Lives
|
|
at `op_fraud::features::PaymentDescriptor`. Found by grepping
|
|
`pub use` in op-fraud/src/lib.rs.
|
|
|
|
5. **`PaymentDescriptor::has_remittance` field missed.** Initial
|
|
`PaymentDescriptor { ... }` literal omitted the field. Fixed.
|
|
|
|
6. **`CardAcquirer` method signatures wrong on test stubs.** My
|
|
FakeAcquirer test double had `capture(&self, _id: &str,
|
|
_amount: Option<Money>) -> Result<AuthDecision>`. Real trait
|
|
has `capture(&self, req: &CaptureRequest) -> Result<AuthDecision>`
|
|
with a structured request type. Fixed.
|
|
|
|
7. **`op_rails_card::Error` variants wrong.** First draft used
|
|
`Network`, `Auth`, `Backend`, `Timeout`. Real variants:
|
|
`Transport`, `PspRejected{status,code,message}`, `MissingField`,
|
|
`Parse`, `UnknownStatus`, `UnsupportedMethod`, `Core`,
|
|
`DriverValidation`. Fixed in `classify_error`.
|
|
|
|
8. **`AuthStatus` not re-exported at op_rails_card crate root.**
|
|
Only `AuthDecision`, `AuthRequest`, `CardAcquirer`,
|
|
`CaptureRequest`, `RefundRequest`, `VoidRequest` are at root.
|
|
`AuthStatus`, `ThreeDsMode`, `VoidReason` need
|
|
`op_rails_card::acquirer::*`. Same shape for
|
|
`op_rails_a2a` — `A2aStatus` is re-exported but
|
|
`PaymentDescriptor` for op-fraud is not.
|
|
|
|
9. **Bash brace expansion didn't work in `mkdir -p .../src/{router,engine}`.**
|
|
The bash tool's shell created a literal directory named
|
|
`{router,engine}` instead of two directories `router/` and
|
|
`engine/`. Caught by `ls` later; cleaned with `rm -rf
|
|
'{router,engine}'`. The real `router.rs` and `engine.rs`
|
|
were created as single files at the correct location, so no
|
|
functional impact, but a tidiness reminder for future shell
|
|
commands.
|
|
|
|
10. **Kiosk scenario 2 was architecturally wrong on first draft.**
|
|
I framed it as "card→A2A fallback" but a `Vault` PaymentMethod
|
|
cannot be paid through an A2A rail — those need an account
|
|
number, not a card token. The `PolicyRouter::method_supports`
|
|
filter would correctly drop A2A from the chain, but the demo
|
|
scenario would have shown a misleading single-rail attempt.
|
|
Fixed by reframing as within-card-rail PSP fallback (primary
|
|
Hyperswitch → backup Stripe), which is the actual common
|
|
production pattern.
|
|
|
|
## What's next
|
|
|
|
Phase 11 closes the architectural completion of OpenPay's core stack.
|
|
All 10 prior phases now have at least one orchestrator path
|
|
exercising them end-to-end.
|
|
|
|
Possible Phase 12+ directions (not committed):
|
|
|
|
- **`op-ledger`** — double-entry bookkeeping crate. Persists every
|
|
authorize / capture / refund / void as ledger entries, exposes a
|
|
reconciliation API.
|
|
- **`op-webhook`** — async webhook fanout. Pluggable HTTP delivery
|
|
with retry, signing, replay-protection.
|
|
- **`op-orchestrator-async`** — async wrapper around the sync
|
|
orchestrator using `tokio::task::spawn_blocking` for callers that
|
|
need to integrate with an existing async runtime.
|
|
- **Persistent stores** — Redis-backed `IdempotencyStore` and
|
|
`CircuitBreaker` for multi-instance deployments.
|
|
- **OpenTelemetry integration** — single trace ID propagated from
|
|
checkout through every layer, exposed as `IntentTraceId` on
|
|
`PaymentIntent`.
|