open-standards/openpay/crates/op-orchestrator/src/engine.rs
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

823 lines
30 KiB
Rust

//! The orchestrator engine.
//!
//! Wires intent → fraud check → routing → attempt loop → outcome.
//!
//! ## Adapter-per-driver
//!
//! 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, etc). Rather than bake all those
//! fields into the generic [`PaymentIntent`], we delegate
//! intent-to-rail-request translation to a per-driver
//! [`RailAdapter`].
//!
//! The orchestrator owns a `HashMap<(rail, driver), Arc<dyn
//! RailAdapter>>` and dispatches by name. Operators register their
//! adapters at startup.
//!
//! This trades a small amount of boilerplate per driver for a
//! coherent orchestrator that doesn't need to know about every rail
//! protocol. New rails (UPI, RTP, etc.) plug in by writing an
//! adapter — no changes to the engine.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use op_core::{PaymentMethod, RailKind};
use op_fraud::features::PaymentDescriptor;
use op_fraud::{
FeatureVector, FraudDecision, Scorer, ScoringContext, Thresholds, extract_features,
};
use crate::circuit_breaker::{CircuitBreaker, InMemoryCircuitBreaker};
use crate::error::{Error, Result};
use crate::idempotency::{IdempotencyStore, InMemoryIdempotencyStore};
use crate::intent::PaymentIntent;
use crate::outcome::{Attempt, AttemptOutcome, OrchestrationOutcome, TerminalStatus};
use crate::router::{PolicyRouter, Router};
/// Per-driver adapter that maps a generic [`PaymentIntent`] onto its
/// underlying rail's request type, calls the driver, and maps the
/// response onto an [`AttemptOutcome`].
///
/// Implementations live downstream — `op-rails-card::adapter::HyperswitchAdapter`,
/// `op-rails-a2a::adapter::FedNowAdapter`, etc. The orchestrator only
/// sees this trait.
///
/// Returns a tuple `(AttemptOutcome, Option<rail_specific_id>)` so
/// the orchestrator can populate
/// [`OrchestrationOutcome::psp_payment_id`] /
/// [`OrchestrationOutcome::uetr`] on success.
pub trait RailAdapter: Send + Sync {
/// Driver name. Must match what the router emits in
/// [`RailChoice::driver`].
fn driver(&self) -> &str;
/// Which rail this adapter speaks (Card or A2A).
fn rail(&self) -> RailKind;
/// Attempt the payment. The orchestrator passes the intent and
/// the attempt index (0-based) so adapters that want to vary
/// their behavior across attempts (e.g. force 3DS on retry) can
/// see the count.
fn attempt(&self, intent: &PaymentIntent, attempt_number: usize) -> AdapterResult;
/// Resume a previously-paused attempt after the customer
/// completed an out-of-band challenge (3DS, bank app redirect,
/// OTP). The orchestrator calls this when
/// [`Orchestrator::resume`] is invoked with a `psp_payment_id`
/// matching an earlier `RequiresAction` outcome.
///
/// Default impl returns `SoftFailure { code:
/// "resume_not_supported" }` so adapters that don't have a
/// challenge flow stay correct without needing to implement
/// anything.
fn resume(&self, intent: &PaymentIntent, psp_payment_id: &str) -> AdapterResult {
let _ = (intent, psp_payment_id);
AdapterResult {
outcome: AttemptOutcome::SoftFailure {
code: "resume_not_supported".to_owned(),
},
psp_payment_id: None,
uetr: None,
}
}
}
/// What a [`RailAdapter::attempt`] call returns.
#[derive(Clone, Debug)]
pub struct AdapterResult {
/// Outcome of this attempt.
pub outcome: AttemptOutcome,
/// PSP-issued payment id, if the rail returned one (Card only).
pub psp_payment_id: Option<String>,
/// UETR echoed back by the rail, if any (A2A only).
pub uetr: Option<String>,
}
/// Backoff policy between retries.
#[derive(Copy, Clone, Debug)]
pub struct BackoffPolicy {
/// Initial delay (caller's notion of seconds — see note below).
pub initial: u64,
/// Multiplier applied per attempt.
pub multiplier: u32,
/// Cap on the per-attempt delay.
pub max_delay: u64,
}
impl Default for BackoffPolicy {
fn default() -> Self {
// 1s, 2s, 4s, 8s, …, capped at 30s.
Self {
initial: 1,
multiplier: 2,
max_delay: 30,
}
}
}
impl BackoffPolicy {
/// Compute the delay for attempt `n` (0-based).
pub fn delay_for(&self, n: usize) -> u64 {
let factor = (self.multiplier as u64).saturating_pow(n as u32);
self.initial.saturating_mul(factor).min(self.max_delay)
}
}
/// Engine configuration knobs.
#[derive(Clone, Debug)]
pub struct OrchestratorConfig {
/// Max number of soft-failure retries before declaring exhaustion.
/// Each rail entry in the routing chain counts toward this cap.
pub max_attempts: usize,
/// Backoff policy between attempts.
pub backoff: BackoffPolicy,
}
impl Default for OrchestratorConfig {
fn default() -> Self {
Self {
max_attempts: 3,
backoff: BackoffPolicy::default(),
}
}
}
/// Builder for the per-intent fraud scoring context.
///
/// Operators with real customer-velocity / device-fingerprint data
/// plug in their own. Default produces an empty context (no
/// velocity, no device info, no prior-payment history) — fine for
/// the reference flow but undertrained in production.
pub type ContextBuilder = Box<dyn Fn(&PaymentIntent) -> ScoringContext + Send + Sync>;
/// The orchestrator.
///
/// Construct one per process. Register rail adapters at startup;
/// `run()` an intent to get an [`OrchestrationOutcome`].
pub struct Orchestrator {
config: OrchestratorConfig,
router: Box<dyn Router>,
adapters: HashMap<(RailKind, String), Arc<dyn RailAdapter>>,
idempotency: Box<dyn IdempotencyStore>,
breaker: Box<dyn CircuitBreaker>,
scorer: Option<Box<dyn Scorer>>,
/// Telemetry sink for completed attempts. Defaults to
/// [`NoOpRailTelemetry`], so existing callers don't change.
/// Wire in a real sink (e.g. `GraphRailTelemetry`) to feed the
/// routing-signals loop.
telemetry: Arc<dyn crate::signals::RailTelemetry>,
/// Fraud thresholds (review / decline / freeze). Default per
/// PCI / industry guidance: 0.50 / 0.80 / 0.95.
thresholds: Thresholds,
/// Builder for the per-intent fraud scoring context.
context_builder: ContextBuilder,
/// Time source (override in tests). Returns unix epoch seconds.
now: Box<dyn Fn() -> u64 + Send + Sync>,
}
impl Orchestrator {
/// Construct with defaults: [`PolicyRouter::default`],
/// [`InMemoryIdempotencyStore`], [`InMemoryCircuitBreaker`], no
/// fraud scorer, default thresholds, empty scoring context,
/// system clock.
pub fn new() -> Self {
Self {
config: OrchestratorConfig::default(),
router: Box::new(PolicyRouter::default()),
adapters: HashMap::new(),
idempotency: Box::new(InMemoryIdempotencyStore::new()),
breaker: Box::new(InMemoryCircuitBreaker::new()),
scorer: None,
telemetry: crate::signals::noop_telemetry(),
thresholds: Thresholds::default(),
context_builder: Box::new(|_| ScoringContext::default()),
now: Box::new(default_now),
}
}
/// Builder: set the fraud thresholds.
#[must_use]
pub fn with_thresholds(mut self, t: Thresholds) -> Self {
self.thresholds = t;
self
}
/// Builder: set the per-intent scoring context builder.
#[must_use]
pub fn with_context_builder<F: Fn(&PaymentIntent) -> ScoringContext + Send + Sync + 'static>(
mut self,
f: F,
) -> Self {
self.context_builder = Box::new(f);
self
}
/// Set the configuration.
#[must_use]
pub fn with_config(mut self, config: OrchestratorConfig) -> Self {
self.config = config;
self
}
/// Set the router.
#[must_use]
pub fn with_router(mut self, router: Box<dyn Router>) -> Self {
self.router = router;
self
}
/// Set the idempotency store.
#[must_use]
pub fn with_idempotency_store(mut self, store: Box<dyn IdempotencyStore>) -> Self {
self.idempotency = store;
self
}
/// Set the circuit breaker.
#[must_use]
pub fn with_circuit_breaker(mut self, breaker: Box<dyn CircuitBreaker>) -> Self {
self.breaker = breaker;
self
}
/// Set the fraud scorer.
#[must_use]
pub fn with_scorer(mut self, scorer: Box<dyn Scorer>) -> Self {
self.scorer = Some(scorer);
self
}
/// Wire in a [`RailTelemetry`](crate::RailTelemetry) sink. After
/// each `RailAdapter::attempt` completes, the orchestrator
/// pushes a `(rail, driver, outcome)` record here. Pair this
/// with a [`RoutingSignals`](crate::RoutingSignals) source on
/// the router to close the "prefer quiet rails" loop.
#[must_use]
pub fn with_telemetry(mut self, telemetry: Arc<dyn crate::signals::RailTelemetry>) -> Self {
self.telemetry = telemetry;
self
}
/// Set a deterministic clock (for tests).
#[must_use]
pub fn with_clock<F: Fn() -> u64 + Send + Sync + 'static>(mut self, now: F) -> Self {
self.now = Box::new(now);
self
}
/// Register a rail adapter.
pub fn register_adapter(&mut self, adapter: Arc<dyn RailAdapter>) {
let key = (adapter.rail(), adapter.driver().to_owned());
self.adapters.insert(key, adapter);
}
/// True iff a `(rail, driver)` adapter has been registered.
/// Introspection helper for boot-time config tests — production
/// code routes via [`Self::run`] / [`Router`], not by name lookup.
#[must_use]
pub fn has_driver(&self, rail: RailKind, driver: &str) -> bool {
self.adapters.contains_key(&(rail, driver.to_owned()))
}
/// Iterate registered `(rail, driver)` keys. Useful for boot
/// diagnostics and operator dashboards.
pub fn registered_drivers(&self) -> impl Iterator<Item = (RailKind, &str)> + '_ {
self.adapters
.keys()
.map(|(rail, driver)| (*rail, driver.as_str()))
}
/// Resume an intent that previously returned
/// [`TerminalStatus::RequiresCustomerAction`]. The caller
/// provides the same `intent` plus the `(rail, driver)` that
/// issued the challenge and the `psp_payment_id` returned at
/// challenge time. The orchestrator calls
/// [`RailAdapter::resume`] on the matching adapter and
/// reclassifies the outcome.
///
/// The cached idempotency record is updated with the resumed
/// outcome so subsequent retries (with the same intent body)
/// short-circuit to the resolved state.
///
/// # Errors
/// - [`Error::NoEligibleRail`] if the `(rail, driver)` isn't
/// registered.
/// - Bubbles up adapter / store errors.
#[tracing::instrument(
name = "orchestrator.resume",
skip(self, intent),
fields(
idempotency_key = %intent.idempotency_key.as_str(),
driver = %driver,
rail = ?rail,
),
)]
pub fn resume(
&self,
intent: &PaymentIntent,
rail: RailKind,
driver: &str,
psp_payment_id: &str,
) -> Result<OrchestrationOutcome> {
let key = (rail, driver.to_owned());
let adapter = self
.adapters
.get(&key)
.ok_or_else(|| Error::NoEligibleRail {
reason: format!("no adapter for rail={rail:?} driver={driver}"),
})?;
let result = adapter.resume(intent, psp_payment_id);
let attempt = Attempt {
rail,
driver: driver.to_owned(),
outcome: result.outcome.clone(),
};
let terminal = match &result.outcome {
AttemptOutcome::Success => TerminalStatus::Approved,
AttemptOutcome::RequiresAction { .. } => TerminalStatus::RequiresCustomerAction,
AttemptOutcome::HardDecline { .. } | AttemptOutcome::SoftFailure { .. } => {
TerminalStatus::Declined
}
};
let outcome = OrchestrationOutcome {
terminal_status: terminal,
attempts: vec![attempt],
rail_used: Some(rail),
psp_payment_id: result.psp_payment_id.clone(),
uetr: result.uetr.clone(),
};
// Update the idempotency cache so a re-run with the same key
// sees the resolved outcome rather than re-running the
// challenge.
self.idempotency.commit(&intent.idempotency_key, &outcome);
Ok(outcome)
}
/// Process an intent. The main entry point.
#[tracing::instrument(
name = "orchestrator.run",
skip(self, intent),
fields(
idempotency_key = %intent.idempotency_key.as_str(),
amount_minor = intent.amount.minor_units,
currency = intent.amount.currency.code(),
),
)]
pub fn run(&self, intent: &PaymentIntent) -> Result<OrchestrationOutcome> {
// 1. Idempotency check — reserve a slot or return cached
// outcome.
let body_sig = intent.body_signature();
if let Some(record) = self.idempotency.reserve(&intent.idempotency_key, &body_sig) {
if record.body_signature != body_sig {
return Err(Error::IdempotencyMismatch);
}
if let Some(cached) = record.outcome {
return Ok(cached);
}
// In-flight slot — another request is currently
// processing this key. Treat as a transient: the caller
// can retry. (A more sophisticated impl would block on
// a condvar.)
return Err(Error::AllRailsExhausted { attempt_count: 0 });
}
// From here on, we MUST commit or release before returning.
let result = self.run_inner(intent);
match &result {
Ok(outcome) => self.idempotency.commit(&intent.idempotency_key, outcome),
Err(_) => self.idempotency.release(&intent.idempotency_key),
}
result
}
fn run_inner(&self, intent: &PaymentIntent) -> Result<OrchestrationOutcome> {
// 2. Fraud check (if a scorer is configured).
if let Some(scorer) = &self.scorer {
let rail = implied_rail(&intent.method);
let descriptor = PaymentDescriptor {
amount: intent.amount,
method: &intent.method,
rail,
creditor_account: None,
creditor_name: None,
debtor_account: None,
has_remittance: false,
};
let ctx = (self.context_builder)(intent);
let features: FeatureVector = extract_features(&descriptor, &ctx)?;
let score = scorer.score(&features)?;
let decision = self.thresholds.decide(score)?;
match decision {
FraudDecision::Approve => { /* proceed */ }
FraudDecision::Review => {
return Err(Error::FraudReviewRequired {
reason: format!("score={score:.3} (review threshold)"),
});
}
FraudDecision::Decline => {
return Err(Error::FraudDeclined {
reason: format!("score={score:.3} (decline threshold)"),
});
}
FraudDecision::Freeze => {
return Err(Error::FraudDeclined {
reason: format!("score={score:.3} (freeze threshold)"),
});
}
}
}
// 3. Routing.
let decision = self
.router
.route(intent)
.map_err(|reason| Error::NoEligibleRail { reason })?;
// 4. Attempt loop.
let mut attempts = Vec::new();
let mut any_circuit_was_closed = false;
for (i, choice) in decision.chain.iter().enumerate() {
if attempts.len() >= self.config.max_attempts {
break;
}
let now = (self.now)();
if !self.breaker.allow(choice.rail, &choice.driver, now) {
// Skip rails whose breaker is open; do not count as
// an attempt against max_attempts.
continue;
}
any_circuit_was_closed = true;
let Some(adapter) = self.adapters.get(&(choice.rail, choice.driver.clone())) else {
// Misconfigured: router pointed to a driver we don't
// have an adapter for. Skip and continue.
continue;
};
let start = std::time::Instant::now();
let result = adapter.attempt(intent, i);
let duration_ms = u32::try_from(start.elapsed().as_millis()).unwrap_or(u32::MAX);
let outcome = result.outcome.clone();
// Feed the telemetry sink before we mutate `outcome`
// further; this is what closes the loop with any
// RoutingSignals source the router consults next turn.
// The idempotency key is the operator-supplied
// correlation token; backed stores use it to join this
// attempt against the resulting ledger transaction and
// any reconciliation discrepancies that touch it.
self.telemetry.record_attempt(
choice.rail,
&choice.driver,
crate::signals::AttemptResultClass::classify(&outcome),
now,
Some(intent.idempotency_key.as_str()),
Some(duration_ms),
);
attempts.push(Attempt {
rail: choice.rail,
driver: choice.driver.clone(),
outcome: result.outcome,
});
match outcome {
AttemptOutcome::Success => {
self.breaker.record_success(choice.rail, &choice.driver);
return Ok(OrchestrationOutcome {
terminal_status: TerminalStatus::Approved,
attempts,
rail_used: Some(choice.rail),
psp_payment_id: result.psp_payment_id,
uetr: result.uetr,
});
}
AttemptOutcome::RequiresAction { .. } => {
// Customer action is "terminal-pending" — we do
// not retry; we surface and stop.
return Ok(OrchestrationOutcome {
terminal_status: TerminalStatus::RequiresCustomerAction,
attempts,
rail_used: Some(choice.rail),
psp_payment_id: result.psp_payment_id,
uetr: result.uetr,
});
}
AttemptOutcome::HardDecline { .. } => {
// Customer-side decline. Do NOT fall back to
// another rail — the customer must fix it
// (insufficient funds, frozen account, expired).
self.breaker.record_success(choice.rail, &choice.driver);
return Ok(OrchestrationOutcome {
terminal_status: TerminalStatus::Declined,
attempts,
rail_used: Some(choice.rail),
psp_payment_id: result.psp_payment_id,
uetr: result.uetr,
});
}
AttemptOutcome::SoftFailure { .. } => {
// Transient — record breaker failure and try
// the next entry in the chain (which may be a
// fallback rail).
self.breaker
.record_failure(choice.rail, &choice.driver, now);
continue;
}
}
}
if !any_circuit_was_closed {
return Err(Error::AllCircuitsOpen);
}
Err(Error::AllRailsExhausted {
attempt_count: attempts.len(),
})
}
}
impl Default for Orchestrator {
fn default() -> Self {
Self::new()
}
}
fn default_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Map a payment method to the rail it implies for purposes of
/// fraud feature extraction.
///
/// The fraud feature vector has separate `is_rail_card` /
/// `is_rail_a2a` / `is_rail_wallet` / `is_rail_qr` indicators
/// (features 24-27). The fraud check fires BEFORE routing, so we
/// pick the rail the router would most likely choose:
///
/// - `Vault`, `Wallet`, `Emv` → `Card` (PSPs handle these).
/// - `A2a` → `A2a` (FedNow / RTP / PIX / SEPA Inst).
/// - `Qr` → `A2a` (UPI QR, PIX QR all settle on A2A rails).
///
/// If the router later picks a different rail, the score is the
/// same for THIS feature row; richer pipelines re-score per chain
/// entry, but that's an operator extension, not the reference flow.
fn implied_rail(method: &PaymentMethod) -> RailKind {
match method {
PaymentMethod::Vault(_) | PaymentMethod::Wallet(_) | PaymentMethod::Emv(_) => {
RailKind::Card
}
PaymentMethod::A2a(_) | PaymentMethod::Qr(_) => RailKind::A2a,
PaymentMethod::Crypto(_) => RailKind::Crypto,
// Workspace feature unification surfaces this variant via op-vault.
// Design contract: raw PAN never leaves the vault — reaching this
// arm in the orchestrator is a bug, not a runtime condition.
PaymentMethod::RawPan(_) => RailKind::Card,
}
}
#[cfg(test)]
mod tests {
use super::*;
use op_core::{Currency, Money, PaymentMethod, VaultRef};
use crate::idempotency::IdempotencyKey;
/// Always-success card adapter for tests.
struct AlwaysApproveCard;
impl RailAdapter for AlwaysApproveCard {
fn driver(&self) -> &str {
"hyperswitch"
}
fn rail(&self) -> RailKind {
RailKind::Card
}
fn attempt(&self, _intent: &PaymentIntent, _n: usize) -> AdapterResult {
AdapterResult {
outcome: AttemptOutcome::Success,
psp_payment_id: Some("psp_approved_1".into()),
uetr: None,
}
}
}
/// Always-soft-fail card adapter.
struct AlwaysSoftFailCard;
impl RailAdapter for AlwaysSoftFailCard {
fn driver(&self) -> &str {
"flaky"
}
fn rail(&self) -> RailKind {
RailKind::Card
}
fn attempt(&self, _intent: &PaymentIntent, _n: usize) -> AdapterResult {
AdapterResult {
outcome: AttemptOutcome::SoftFailure {
code: "timeout".into(),
},
psp_payment_id: None,
uetr: None,
}
}
}
/// Always-hard-decline card adapter.
struct AlwaysDeclineCard;
impl RailAdapter for AlwaysDeclineCard {
fn driver(&self) -> &str {
"hyperswitch"
}
fn rail(&self) -> RailKind {
RailKind::Card
}
fn attempt(&self, _intent: &PaymentIntent, _n: usize) -> AdapterResult {
AdapterResult {
outcome: AttemptOutcome::HardDecline {
code: "insufficient_funds".into(),
},
psp_payment_id: Some("psp_declined_1".into()),
uetr: None,
}
}
}
fn vault_intent(key: &str, amount_minor: i64) -> PaymentIntent {
PaymentIntent::new(
IdempotencyKey::new(key),
Money::from_minor(amount_minor, Currency::USD),
PaymentMethod::Vault(VaultRef::new("tok_v7_a")),
)
}
#[test]
fn approve_happy_path() {
let mut orch = Orchestrator::new();
orch.register_adapter(Arc::new(AlwaysApproveCard));
let out = orch.run(&vault_intent("k1", 1000)).unwrap();
assert!(out.is_approved());
assert_eq!(out.attempts.len(), 1);
assert_eq!(out.rail_used, Some(RailKind::Card));
assert_eq!(out.psp_payment_id.as_deref(), Some("psp_approved_1"));
}
#[test]
fn hard_decline_does_not_retry() {
let mut orch = Orchestrator::new();
orch.register_adapter(Arc::new(AlwaysDeclineCard));
let out = orch.run(&vault_intent("k2", 1000)).unwrap();
assert!(out.is_declined());
assert_eq!(out.attempts.len(), 1);
}
#[test]
fn soft_failure_exhausts_chain() {
// Single driver in the chain that always soft-fails →
// AllRailsExhausted.
let router = PolicyRouter::new(vec!["flaky".into()], vec![]);
let mut orch = Orchestrator::new().with_router(Box::new(router));
orch.register_adapter(Arc::new(AlwaysSoftFailCard));
let err = orch.run(&vault_intent("k3", 1000)).unwrap_err();
assert!(matches!(err, Error::AllRailsExhausted { attempt_count: 1 }));
}
#[test]
fn idempotency_replay_returns_cached_outcome() {
let mut orch = Orchestrator::new();
orch.register_adapter(Arc::new(AlwaysApproveCard));
let intent = vault_intent("kreplay", 1000);
let first = orch.run(&intent).unwrap();
let second = orch.run(&intent).unwrap();
assert!(first.is_approved());
assert!(second.is_approved());
// Both should reference the same psp_payment_id — the second
// call returned a cached outcome, did NOT re-charge.
assert_eq!(first.psp_payment_id, second.psp_payment_id);
}
#[test]
fn idempotency_mismatch_rejects_different_body() {
let mut orch = Orchestrator::new();
orch.register_adapter(Arc::new(AlwaysApproveCard));
let i1 = vault_intent("kmismatch", 1000);
let mut i2 = vault_intent("kmismatch", 1000);
i2.amount = Money::from_minor(9999, Currency::USD);
orch.run(&i1).unwrap();
let err = orch.run(&i2).unwrap_err();
assert!(matches!(err, Error::IdempotencyMismatch));
}
#[test]
fn no_adapter_registered_returns_exhausted() {
// Router produces a chain but no adapter is registered for
// the named driver. Engine skips each entry → AllCircuitsOpen
// (no breaker ever opened, no attempts ever made — the
// "no attempt was even possible" signal is AllCircuitsOpen
// by current impl).
//
// Actually re-reading: any_circuit_was_closed only flips
// when we PASSED the breaker check. So with no adapters at
// all but breakers all closed, we DO pass the breaker check
// and then the adapter-not-found `continue` runs. attempts
// stays empty → AllRailsExhausted { attempt_count: 0 }.
let orch = Orchestrator::new();
let err = orch.run(&vault_intent("kno", 1000)).unwrap_err();
assert!(matches!(err, Error::AllRailsExhausted { attempt_count: 0 }));
}
#[test]
fn circuit_breaker_short_circuits_failing_driver() {
// Configure a low threshold so a few soft failures open the
// breaker. After it opens, the engine reports AllCircuitsOpen.
let cb = InMemoryCircuitBreaker::new()
.with_threshold(2)
.with_cooldown(3600);
let router = PolicyRouter::new(vec!["flaky".into()], vec![]);
let mut orch = Orchestrator::new()
.with_router(Box::new(router))
.with_circuit_breaker(Box::new(cb))
.with_clock(|| 1000);
orch.register_adapter(Arc::new(AlwaysSoftFailCard));
// First call: max_attempts=3, but only 1 chain entry. The
// single driver soft-fails once → breaker records 1 failure
// → still closed → loop ends because chain is exhausted →
// AllRailsExhausted.
let err1 = orch.run(&vault_intent("k-cb-1", 1000)).unwrap_err();
assert!(matches!(err1, Error::AllRailsExhausted { .. }));
// Second call: breaker records 2nd failure → opens. But
// this call ITSELF triggers the failure; the breaker check
// happens before the call. So second call still attempts,
// fails, and trips the breaker.
let err2 = orch.run(&vault_intent("k-cb-2", 1000)).unwrap_err();
assert!(matches!(err2, Error::AllRailsExhausted { .. }));
// Third call: breaker is open from the start → no attempts
// → AllCircuitsOpen.
let err3 = orch.run(&vault_intent("k-cb-3", 1000)).unwrap_err();
assert!(matches!(err3, Error::AllCircuitsOpen));
}
#[test]
fn cross_rail_fallback_on_soft_failure() {
// Chain: flaky-card (soft-fails) → backup-card (approves).
let router = PolicyRouter::new(vec!["flaky".into(), "hyperswitch".into()], vec![]);
let mut orch = Orchestrator::new().with_router(Box::new(router));
orch.register_adapter(Arc::new(AlwaysSoftFailCard));
orch.register_adapter(Arc::new(AlwaysApproveCard));
let out = orch.run(&vault_intent("k-fb", 1000)).unwrap();
assert!(out.is_approved());
assert_eq!(out.attempts.len(), 2);
assert_eq!(out.attempts[0].driver, "flaky");
assert_eq!(out.attempts[1].driver, "hyperswitch");
assert_eq!(out.rail_used, Some(RailKind::Card));
}
#[test]
fn backoff_policy_grows_geometrically_with_cap() {
let p = BackoffPolicy::default();
assert_eq!(p.delay_for(0), 1);
assert_eq!(p.delay_for(1), 2);
assert_eq!(p.delay_for(2), 4);
assert_eq!(p.delay_for(3), 8);
assert_eq!(p.delay_for(4), 16);
// 32 capped at 30
assert_eq!(p.delay_for(5), 30);
assert_eq!(p.delay_for(99), 30);
}
#[test]
fn max_attempts_caps_loop() {
// max_attempts=2 with 3 flaky entries → only 2 attempts.
let router =
PolicyRouter::new(vec!["flaky".into(), "flaky".into(), "flaky".into()], vec![]);
// Note: registering "flaky" 3x is the same adapter; the
// chain has 3 entries pointing at it.
let mut orch = Orchestrator::new()
.with_router(Box::new(router))
.with_config(OrchestratorConfig {
max_attempts: 2,
backoff: BackoffPolicy::default(),
});
orch.register_adapter(Arc::new(AlwaysSoftFailCard));
let err = orch.run(&vault_intent("k-cap", 1000)).unwrap_err();
assert!(matches!(err, Error::AllRailsExhausted { attempt_count: 2 }));
}
}