open-standards/openpay/docs/11-orchestrator-progress.md
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
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.
2026-05-23 07:58:09 -04:00

20 KiB

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

                       ┌───────────────────────────────┐
                       │  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.