Skip to main content

camel_integration_test/
runner.rs

1//! The scenario action runner (ADR-0069 §5, §7).
2//!
3//! Executes a scenario's ordered actions against a
4//! [`PartnerRouter`]: `send`
5//! dispatches through the adapter, `receive` awaits with the action's
6//! deadline and applies `extract` into [`ScenarioVars`], `sleep` uses
7//! tokio time, and `validate` evaluates the matcher grammar against
8//! the last received message or an extracted variable, or asserts a
9//! partner's recorded-request count (feature `http`).
10//!
11//! Failure taxonomy (ADR-0069 §7), encoded by variant and named in
12//! `Display`, never by message text alone:
13//!
14//! - Verdict class — the scenario ran and the system under test
15//!   failed it: [`ScenarioFailure::ReceiveTimeout`],
16//!   [`ScenarioFailure::ValidationMismatch`].
17//! - Apparatus class — the scenario never got a meaningful answer:
18//!   [`ScenarioFailure::VarUnresolved`] (an unset variable is an
19//!   authoring bug, bd rc-whof),
20//!   [`ScenarioFailure::ActionTransport`],
21//!   [`ScenarioFailure::PartnerStartup`],
22//!   [`ScenarioFailure::ShutdownFailure`],
23//!   [`ScenarioFailure::LogCaptureUnavailable`].
24//!
25//! Verdict-class failures map to exit 1 at the CLI; apparatus-class
26//! failures map to exit 2, as do doc-validation failures before the
27//! runner ever runs.
28//!
29//! Every await is bounded: `receive` carries the action deadline, and
30//! `send` is bounded by the document's `sendDeadline`, defaulting to
31//! `SEND_DEADLINE`.
32
33use std::collections::BTreeMap;
34use std::sync::Arc;
35use std::time::{Duration, Instant};
36
37use camel_api::datasource::DatasourceCatalog;
38use camel_api::{Body, Exchange, Value};
39use camel_matchers::{expectation_matches, stringify};
40
41use crate::adapters::redact_wire_path;
42use crate::adapters::{
43    IncomingMessage, OutgoingMessage, PartnerRouter, ReceiveError, TransportError, lanes_suffix,
44};
45use crate::document::{
46    EndpointRef, Expectation, LogLevel, LogsAssertion, Provisioning, ScenarioAction,
47    ScenarioDocument, ScenarioTarget, ValidateExpectation,
48};
49
50/// Partner verification for the `validate` action's `partner` target
51/// (ADR-0069 §5): the filtered recorded-request count, the deadline
52/// poll, and the mismatch-detail renderers.
53mod partner_validate;
54
55#[cfg(test)]
56mod partner_validate_test;
57
58// Test-only re-exports: these primitives are exercised directly by
59// `runner_test`, while the runner itself only calls
60// `partner_validate_action`.
61use partner_validate::partner_validate_action;
62#[cfg(all(test, feature = "http"))]
63pub(crate) use partner_validate::{
64    matching_requests, partner_mismatch_detail, render_bound, render_filters,
65};
66
67/// SQL row-shape verification for the `validate` action's `sql`
68/// target (bd rc-25lup.2): pool resolution, the fail-closed row
69/// mapping, the by-name projection, the non-monotone poll lattice,
70/// and the cell-free mismatch renderer.
71mod sql_validate;
72
73// The dispatch target of the runner's sql validate arm; the
74// re-exports below are its direct unit tests (`sql_validate_test`,
75// which compiles in BOTH feature configurations — hence the twin).
76#[cfg(all(test, feature = "sql"))]
77pub(crate) use sql_validate::any_row_to_tuple;
78pub(crate) use sql_validate::sql_validate_action;
79
80/// The default bounded deadline for every `send` action (ADR-0069
81/// §7: every adapter operation carries a deadline). A document-level
82/// `sendDeadline` overrides it (rc-tr4w).
83const SEND_DEADLINE: Duration = Duration::from_secs(30);
84
85/// The effective send bound of a document: its declared
86/// `sendDeadline`, or the thirty-second [`SEND_DEADLINE`] default.
87/// Real time only (ADR-0069 §6: no virtual time).
88pub(crate) fn effective_send_deadline(doc: &ScenarioDocument) -> Duration {
89    doc.send_deadline.unwrap_or(SEND_DEADLINE)
90}
91
92/// Mutable run state carried across actions: scenario variables set by
93/// `extract`, and the last message received per endpoint for
94/// `lastReceived` validation.
95#[derive(Debug, Default)]
96pub struct ScenarioVars {
97    /// Variables extracted from received messages, by name.
98    variables: BTreeMap<String, Value>,
99    /// Last message received per endpoint URI.
100    last_received: BTreeMap<String, IncomingMessage>,
101}
102
103impl ScenarioVars {
104    /// Empty run state.
105    pub fn new() -> Self {
106        Self::default()
107    }
108
109    /// The variable set by an earlier `extract`, if any.
110    pub fn get(&self, name: &str) -> Option<&Value> {
111        self.variables.get(name)
112    }
113
114    /// Sets a variable, overwriting any earlier value.
115    pub fn set(&mut self, name: impl Into<String>, value: Value) {
116        self.variables.insert(name.into(), value);
117    }
118
119    /// The last message received on the endpoint URI, if any.
120    pub fn last_received(&self, endpoint: &str) -> Option<&IncomingMessage> {
121        self.last_received.get(endpoint)
122    }
123
124    /// Records the last message received on an endpoint URI.
125    fn remember(&mut self, endpoint: String, message: IncomingMessage) {
126        self.last_received.insert(endpoint, message);
127    }
128}
129
130/// Resolves `${name}` placeholders in a scenario string against `vars`.
131///
132/// Grammar: `$${` escapes to a literal `${`; `${name}` substitutes the
133/// variable when `name` matches `[A-Za-z0-9_]+` and is immediately
134/// followed by `}`. Anything else — including `${env:FOO}`, where a
135/// colon follows the name — stays literal, so `${env:}` never resolves
136/// in scenarios. A non-string variable substitutes its JSON
137/// representation (`Value::to_string`), so a number 42 yields `42`.
138/// Substituted text is not re-scanned. An unset variable fails with
139/// [`ScenarioFailure::VarUnresolved`].
140pub(crate) fn resolve_placeholders(
141    input: &str,
142    vars: &ScenarioVars,
143) -> Result<String, ScenarioFailure> {
144    let bytes = input.as_bytes();
145    let mut out = Vec::with_capacity(bytes.len());
146    let mut i = 0;
147    while i < bytes.len() {
148        if bytes[i] == b'$' {
149            // `$${` escapes to a literal `${`.
150            if i + 2 < bytes.len() && bytes[i + 1] == b'$' && bytes[i + 2] == b'{' {
151                out.extend_from_slice(b"${");
152                i += 3;
153                continue;
154            }
155            // `${name}` with name in [A-Za-z0-9_]+ immediately followed
156            // by `}`; a colon or any other character after the name
157            // keeps the whole span literal.
158            if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
159                let name_start = i + 2;
160                let mut j = name_start;
161                while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
162                    j += 1;
163                }
164                if j > name_start && j < bytes.len() && bytes[j] == b'}' {
165                    let name = &input[name_start..j];
166                    match vars.get(name) {
167                        Some(value) => {
168                            let replacement = stringify(value);
169                            out.extend_from_slice(replacement.as_bytes());
170                            i = j + 1;
171                            continue;
172                        }
173                        None => {
174                            return Err(ScenarioFailure::VarUnresolved {
175                                name: name.to_string(),
176                            });
177                        }
178                    }
179                }
180            }
181            out.push(b'$');
182            i += 1;
183            continue;
184        }
185        out.push(bytes[i]);
186        i += 1;
187    }
188    // The output is a byte-for-byte copy of the input except for
189    // substituted spans, so it stays valid UTF-8.
190    Ok(String::from_utf8(out).expect("placeholder output preserves input UTF-8")) // allow-unwrap
191}
192
193/// Recursively interpolates `${name}` placeholders in a value: maps
194/// and arrays are rebuilt with interpolated values, string leaves go
195/// through [`resolve_placeholders`], and every other leaf is cloned
196/// untouched. An unset variable propagates
197/// [`ScenarioFailure::VarUnresolved`] from any depth.
198pub(crate) fn interpolate_value(
199    value: &Value,
200    vars: &ScenarioVars,
201) -> Result<Value, ScenarioFailure> {
202    match value {
203        Value::String(text) => Ok(Value::String(resolve_placeholders(text, vars)?)),
204        Value::Array(items) => items
205            .iter()
206            .map(|item| interpolate_value(item, vars))
207            .collect::<Result<Vec<_>, _>>()
208            .map(Value::Array),
209        Value::Object(map) => {
210            let rebuilt = map
211                .iter()
212                .map(|(key, item)| Ok((key.clone(), interpolate_value(item, vars)?)))
213                .collect::<Result<_, _>>()?;
214            Ok(Value::Object(rebuilt))
215        }
216        other => Ok(other.clone()),
217    }
218}
219
220/// The outcome of a scenario that ran to completion: every action
221/// succeeded and every validation passed.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223#[non_exhaustive]
224pub enum ScenarioVerdict {
225    /// All actions completed and all validations passed.
226    Pass,
227}
228
229/// Why a scenario failed (ADR-0069 §7). Verdict-class variants mean
230/// the system under test failed the scenario; apparatus-class
231/// variants mean the scenario never got a meaningful answer. The
232/// CLI maps verdict-class failures to exit 1 and apparatus-class
233/// failures to exit 2; doc validation also maps to exit 2.
234#[derive(Debug, Clone, PartialEq, thiserror::Error)]
235#[non_exhaustive]
236pub enum ScenarioFailure {
237    /// Nothing reached the partner before the deadline (verdict
238    /// class, `receive-timeout`).
239    #[error("receive-timeout: {endpoint} delivered nothing within {deadline:?}{lanes}")]
240    ReceiveTimeout {
241        /// The endpoint URI that delivered nothing.
242        endpoint: String,
243        /// The deadline that elapsed.
244        deadline: Duration,
245        /// Rendered lane evidence (already redacted, ADR-0051): the
246        /// `; no arrival matched; lanes recorded: [...]` suffix, or
247        /// empty when the construction site saw no lanes.
248        lanes: String,
249    },
250    /// A validation failed (verdict class, `validation-mismatch`).
251    #[error("validation-mismatch: action {action}: {detail}")]
252    ValidationMismatch {
253        /// Index of the failing action, zero-based.
254        action: usize,
255        /// What was expected and what arrived.
256        detail: String,
257    },
258    /// A referenced variable was never set (apparatus class,
259    /// `scenario-var-unresolved`, bd rc-whof): an unset variable is an
260    /// authoring bug, not a product failure — the scenario never got a
261    /// meaningful answer.
262    #[error("scenario-var-unresolved: {name}")]
263    VarUnresolved {
264        /// The variable name no `extract` ever set.
265        name: String,
266    },
267    /// A send or receive failed at the transport before any assertion
268    /// ran (apparatus class, `action-transport-failure`).
269    #[error("action-transport-failure: action {action}: {source}")]
270    ActionTransport {
271        /// Index of the failing action, zero-based.
272        action: usize,
273        /// The transport failure.
274        source: TransportError,
275    },
276    /// A partner listener bound but its handler failed to start
277    /// (apparatus class, `partner-startup-failure`). Reserved in v1:
278    /// no adapter separates bind from handler start, and the CLI maps
279    /// bind failures to `partner-bind-failure` doc errors.
280    #[error("partner-startup-failure: {message}")]
281    PartnerStartup {
282        /// Startup failure detail.
283        message: String,
284    },
285    /// The partner's arrival lane dropped arrivals while the scenario
286    /// was not receiving (apparatus class, `arrival-lane-overflow`):
287    /// the harness lost them before the system under test could fail
288    /// the scenario on substance.
289    #[error("arrival-lane-overflow: {endpoint} dropped {dropped} arrivals")]
290    ArrivalLaneOverflow {
291        /// The endpoint URI whose lane dropped arrivals.
292        endpoint: String,
293        /// How many arrivals the lane dropped while full.
294        dropped: usize,
295    },
296    /// Teardown of the boot or a partner timed out or erred after the
297    /// verdict was recorded (apparatus class, `shutdown-failure`).
298    #[error("shutdown-failure: {message}")]
299    ShutdownFailure {
300        /// Teardown failure detail.
301        message: String,
302    },
303    /// The document declares a `logs:` block but the harness's capture
304    /// subscriber does not own the process's tracing seat (apparatus
305    /// class, `log-capture-unavailable`, rc-tdgh5): a foreign tracing
306    /// subscriber won the first-wins `try_init`, so the events the
307    /// block asserts against never reach the harness. The scenario
308    /// never got a meaningful answer.
309    #[error("log-capture-unavailable: {detail}")]
310    LogCaptureUnavailable {
311        /// Why capture cannot run (the foreign-subscriber condition).
312        detail: String,
313    },
314}
315
316/// Fills the harness bind variables into `vars` (ADR-0069 §9): every
317/// wired reference with `provisioning: harness` and a `bindVar` gets
318/// its partner's bound `host:port` authority from the router, so a
319/// scenario string can address the partner as
320/// `http://${NAME}/path`.
321///
322/// Two-layer split: the scenario variable carries `host:port` only;
323/// the env-tier binding that route files interpolate keeps its
324/// `http://host:port` form (owned by the CLI driver, unchanged here).
325/// A reference with no registered adapter or no bound authority is
326/// skipped: the variable stays unset, and a later use fails with the
327/// apparatus-class `VarUnresolved`.
328pub fn fill_bind_vars(wired: &[EndpointRef], router: &PartnerRouter, vars: &mut ScenarioVars) {
329    for reference in wired {
330        if reference.provisioning != Some(Provisioning::Harness) {
331            continue;
332        }
333        let Some(bind_var) = reference.bind_var.as_deref() else {
334            continue;
335        };
336        let Some(authority) = router
337            .adapter(&reference.endpoint)
338            .and_then(|adapter| adapter.bound_authority())
339        else {
340            continue;
341        };
342        vars.set(bind_var, Value::String(authority));
343    }
344}
345
346/// Runs a scenario's actions in order against the router.
347///
348/// On success every action completed; on failure the variant names
349/// the ADR-0069 §7 class. `vars` carries extraction results and
350/// last-received state both into and out of the run.
351pub async fn run_scenario(
352    doc: &ScenarioDocument,
353    router: &PartnerRouter,
354    vars: &mut ScenarioVars,
355) -> Result<ScenarioVerdict, ScenarioFailure> {
356    // The scenario-start anchor every `elapsedAtLeast` bound measures
357    // against; taken once per run, before the first action.
358    let started_at = Instant::now();
359    let send_deadline = effective_send_deadline(doc);
360    for (index, action) in doc.scenario.iter().enumerate() {
361        // The single-action loop has no boot of its own, so a `sql:`
362        // action here has no datasource catalog and fails closed.
363        run_action(action, index, router, vars, started_at, send_deadline, None).await?;
364    }
365    Ok(ScenarioVerdict::Pass)
366}
367
368/// The outcome of executing a whole scenario document
369/// (ADR-0069 sections 5 and 7).
370///
371/// [`run_scenario_document`] fills
372/// `per_action` with one outcome per executed action and stops at the
373/// first failure; `verdict` is `Some(Pass)` only when every action
374/// passed. `final_failure` is the post-verdict slot: the caller that
375/// owns the boot (the CLI, after `BootHandle::shutdown`) records a
376/// `ShutdownFailure` there without masking the recorded verdict.
377#[derive(Debug, Clone, PartialEq)]
378pub struct DocumentOutcome {
379    /// One outcome per executed action, in action order; actions after
380    /// the first failure never ran.
381    pub per_action: Vec<Result<ScenarioVerdict, ScenarioFailure>>,
382    /// `Some(Pass)` when every action completed; `None` after any
383    /// failure.
384    pub verdict: Option<ScenarioVerdict>,
385    /// Post-verdict shutdown failure, recorded by the boot-owning
386    /// caller; empty when teardown is clean or never ran.
387    pub final_failure: Option<ScenarioFailure>,
388    /// The bound address of the document's `inbound:` listener
389    /// (rc-5yon, ADR-0070), filled by the boot-owning caller from
390    /// [`crate::boot_scenario::ScenarioRun::inbound_bound`] after the
391    /// boot, so tests target the ephemeral listener without re-deriving
392    /// it. `None` when the document declares no `inbound:` listener or
393    /// the caller never filled it; the post-boot slot, as
394    /// `final_failure` is the post-verdict slot.
395    pub inbound_bound: Option<std::net::SocketAddr>,
396    /// Document-level `logs:` block violation (rc-tdgh5): a rendered
397    /// diagnostic naming each violated clause — for `noLevelAbove`,
398    /// each offending event's level, target, and message. `Some` only
399    /// when every action passed and the logs evaluation then failed,
400    /// so `verdict` is `None` alongside it. `None` when the document
401    /// declares no `logs:` block, the block passed, an action failed
402    /// first (the block never evaluated), or capture was unavailable
403    /// (the apparatus failure lives in `per_action`).
404    pub logs_failure: Option<String>,
405}
406
407/// Executes a scenario document's actions in order against the
408/// router, one recorded outcome per action, stopping at the first
409/// failure (the whole-document contract, library-level).
410///
411/// `datasource_catalog` is the booted cascade's single datasource
412/// catalog (bd rc-25lup.1): a `sql:` action resolves its pool through
413/// it, so the seeds land in the same pools the routes use. Callers
414/// without a boot pass `None`; a `sql:` action then fails closed
415/// instead of silently seeding nothing.
416///
417/// When the document declares a `logs:` block (rc-tdgh5), a capture
418/// window opens at document start (behind the harness's process-seat
419/// ownership — a foreign subscriber fails the document through
420/// [`ScenarioFailure::LogCaptureUnavailable`] first) and the block
421/// evaluates after the action loop against the window's events: a
422/// violation fills [`DocumentOutcome::logs_failure`] with the verdict
423/// `None`.
424///
425/// Partners route through `router`; a `send` addressed to a context
426/// component reaches the booted system under test through the
427/// context-stimulus adapter the caller registered for that endpoint
428/// (see [`crate::adapters`]). The single-action
429/// [`run_scenario`] loop and this loop share `run_action`.
430pub async fn run_scenario_document(
431    doc: &ScenarioDocument,
432    router: &PartnerRouter,
433    vars: &mut ScenarioVars,
434    datasource_catalog: Option<&Arc<dyn DatasourceCatalog>>,
435) -> DocumentOutcome {
436    // Log-capture window (rc-tdgh5): open at document start when the
437    // document declares a `logs:` block — and only when the harness's
438    // capture subscriber owns the process's tracing seat. A foreign
439    // subscriber won the first-wins race: the document fails through
440    // the apparatus class before any action runs, because the events
441    // the block asserts against would never reach the harness.
442    let capture_window = match &doc.logs {
443        None => None,
444        Some(_) if crate::log_capture::capture_installed() => {
445            Some(crate::log_capture::open_window())
446        }
447        Some(_) => {
448            return DocumentOutcome {
449                per_action: vec![Err(ScenarioFailure::LogCaptureUnavailable {
450                    detail: "the `logs:` block needs the harness log-capture subscriber, but a foreign tracing subscriber owns this process (first-wins try_init); install nothing before the scenario harness".to_string(),
451                })],
452                verdict: None,
453                final_failure: None,
454                logs_failure: None,
455                inbound_bound: None,
456            };
457        }
458    };
459    // The scenario-start anchor every `elapsedAtLeast` bound measures
460    // against; taken once per run, before the first action.
461    let started_at = Instant::now();
462    let send_deadline = effective_send_deadline(doc);
463    let mut per_action = Vec::with_capacity(doc.scenario.len());
464    let mut failed = false;
465    for (index, action) in doc.scenario.iter().enumerate() {
466        if failed {
467            break;
468        }
469        match run_action(
470            action,
471            index,
472            router,
473            vars,
474            started_at,
475            send_deadline,
476            datasource_catalog,
477        )
478        .await
479        {
480            Ok(()) => per_action.push(Ok(ScenarioVerdict::Pass)),
481            Err(failure) => {
482                per_action.push(Err(failure));
483                failed = true;
484            }
485        }
486    }
487    // Logs evaluation (rc-tdgh5): only when no action failed, against
488    // the window that spanned the run. Closing unregisters the window
489    // (conservative attribution for every later window); an action
490    // failure skips evaluation and drops the handle, which unregisters
491    // the window the same way.
492    let logs_failure = match (&doc.logs, capture_window) {
493        (Some(assertion), Some(window)) if !failed => {
494            let events = window.close();
495            evaluate_logs(assertion, &events)
496        }
497        _ => None,
498    };
499    let verdict = if failed || logs_failure.is_some() {
500        None
501    } else {
502        Some(ScenarioVerdict::Pass)
503    };
504    DocumentOutcome {
505        per_action,
506        verdict,
507        final_failure: None,
508        logs_failure,
509        inbound_bound: None,
510    }
511}
512
513/// Evaluates the document-level `logs:` block against the closed
514/// window's events (rc-tdgh5). Conjunction across clauses: every
515/// `contains` marker must appear in at least one event message, every
516/// `regex` entry must match at least one (unanchored), and no event
517/// may carry a level above `noLevelAbove`. `Some(diagnostic)` names
518/// every violated clause; for `noLevelAbove` it lists each offending
519/// event's level, target, and message.
520fn evaluate_logs(
521    assertion: &LogsAssertion,
522    events: &[crate::log_capture::LogEvent],
523) -> Option<String> {
524    let mut violations: Vec<String> = Vec::new();
525    for marker in &assertion.contains {
526        if !events
527            .iter()
528            .any(|event| event.message.contains(marker.as_str()))
529        {
530            violations.push(format!(
531                "`logs.contains` entry `{marker}` matched no captured event"
532            ));
533        }
534    }
535    for pattern in &assertion.regex {
536        // The load-time gate compiled every pattern; a compile failure
537        // here is unreachable, reported rather than panicked (defense
538        // in depth).
539        match regex::Regex::new(pattern) {
540            Ok(compiled) => {
541                if !events.iter().any(|event| compiled.is_match(&event.message)) {
542                    violations.push(format!(
543                        "`logs.regex` entry `{pattern}` matched no captured event"
544                    ));
545                }
546            }
547            Err(error) => violations.push(format!(
548                "`logs.regex` entry `{pattern}` does not compile: {error}"
549            )),
550        }
551    }
552    if let Some(cap) = assertion.no_level_above {
553        let offenders: Vec<&crate::log_capture::LogEvent> = events
554            .iter()
555            .filter(|event| event.level < as_tracing_level(cap))
556            .collect();
557        if !offenders.is_empty() {
558            let listed = offenders
559                .iter()
560                .map(|event| format!("{} {} {}", event.level, event.target, event.message))
561                .collect::<Vec<_>>()
562                .join("; ");
563            violations.push(format!(
564                "`logs.noLevelAbove` violated by {} event(s): {listed}",
565                offenders.len()
566            ));
567        }
568    }
569    if violations.is_empty() {
570        None
571    } else {
572        Some(violations.join("; "))
573    }
574}
575
576/// Maps the document grammar's level onto `tracing`'s ordering.
577/// `tracing` orders levels by verbosity — `TRACE` is the greatest,
578/// `ERROR` the least — so an event more SEVERE than the cap compares
579/// LESS than the cap's level (`event.level < cap`).
580fn as_tracing_level(level: LogLevel) -> tracing::Level {
581    match level {
582        LogLevel::Trace => tracing::Level::TRACE,
583        LogLevel::Debug => tracing::Level::DEBUG,
584        LogLevel::Info => tracing::Level::INFO,
585        LogLevel::Warn => tracing::Level::WARN,
586        LogLevel::Error => tracing::Level::ERROR,
587    }
588}
589
590/// Executes one action at its scenario index. The shared primitive of
591/// [`run_scenario`] and [`run_scenario_document`]; every failure
592/// carries the action index. A `sql:` action seeds through
593/// `datasource_catalog` (`None` fails closed — see
594/// [`run_scenario_document`]).
595async fn run_action(
596    action: &ScenarioAction,
597    index: usize,
598    router: &PartnerRouter,
599    vars: &mut ScenarioVars,
600    started_at: Instant,
601    send_deadline: Duration,
602    datasource_catalog: Option<&Arc<dyn DatasourceCatalog>>,
603) -> Result<(), ScenarioFailure> {
604    match action {
605        ScenarioAction::Send {
606            to,
607            body,
608            headers,
609            method,
610            expect_reply,
611        } => {
612            send_action(
613                index,
614                to,
615                body.as_ref(),
616                headers.as_ref(),
617                method,
618                expect_reply.as_ref(),
619                router,
620                vars,
621                send_deadline,
622            )
623            .await?;
624        }
625        ScenarioAction::Receive {
626            from,
627            deadline,
628            extract,
629        } => {
630            receive_action(index, from, *deadline, extract.as_ref(), router, vars).await?;
631        }
632        ScenarioAction::Sleep { duration } => {
633            tokio::time::sleep(*duration).await;
634        }
635        ScenarioAction::Validate { .. } => {
636            validate_action(action, index, started_at, router, vars, datasource_catalog).await?;
637        }
638        ScenarioAction::Sql {
639            datasource,
640            prepare,
641        } => {
642            // Apparatus class (exit 2): seeding is harness-side state
643            // preparation — a failure here means the scenario never
644            // got its declared preconditions, never that the system
645            // under test misbehaved.
646            #[cfg(feature = "sql")]
647            {
648                let Some(catalog) = datasource_catalog else {
649                    return Err(ScenarioFailure::ActionTransport {
650                        action: index,
651                        source: TransportError::Other {
652                            message: "sql action: no datasource catalog is available; the \
653                                      boot-owning caller must pass the cascade's catalog"
654                                .to_string(),
655                        },
656                    });
657                };
658                let sql = crate::sql_action::SqlAction {
659                    datasource: datasource.clone(),
660                    prepare: prepare.clone(),
661                };
662                crate::sql_action::execute_sql_prepare(catalog, &sql)
663                    .await
664                    .map_err(|message| ScenarioFailure::ActionTransport {
665                        action: index,
666                        source: TransportError::Other { message },
667                    })?;
668            }
669            // Defense-in-depth: the document parser rejects `sql:`
670            // without the feature, so only a directly-constructed
671            // document reaches this arm (the boot-level inbound
672            // precedent).
673            #[cfg(not(feature = "sql"))]
674            {
675                let _ = (datasource, prepare, datasource_catalog);
676                return Err(ScenarioFailure::ActionTransport {
677                    action: index,
678                    source: TransportError::Other {
679                        message: "the `sql:` action requires the `sql` feature, which this \
680                                  harness build does not enable: rebuild with \
681                                  `--features sql` (demand-gated activation)"
682                            .to_string(),
683                    },
684                });
685            }
686        }
687    }
688    Ok(())
689}
690
691/// Dispatches a `send` action, bounded by the document's effective
692/// send deadline ([`effective_send_deadline`]: the declared
693/// `sendDeadline`, or the thirty-second default).
694///
695/// The endpoint reference, the body's string leaves, and the header
696/// values are the complete interpolation surface: each resolves its
697/// `${name}` placeholders against `vars` before dispatch, and an
698/// unresolved variable fails with the apparatus-class `VarUnresolved`.
699/// The dial target comes from the router's address math: a
700/// harness-declared `:0` reference (or a dynamic reference resolving
701/// to a partner authority) dials the partner's bound address with the
702/// interpolated path preserved; anything else dials the interpolated
703/// URI literally.
704///
705/// A declared `expectReply` (rc-qvz6, `direct:` sends only — the
706/// grammar rejected every other scheme at load) asserts the
707/// synchronous reply the adapter returned: a non-matching reply is a
708/// verdict-class [`ScenarioFailure::ValidationMismatch`] naming the
709/// rendered expectation and the actual body, and a missing reply is
710/// an apparatus-class [`ScenarioFailure::ActionTransport`] — the
711/// scenario never got an answer to assert against.
712// The action's flat decomposition (index, endpoint, body, headers,
713// method, reply expectation, router, vars) plus the document send
714// bound threaded from run_action (rc-tr4w).
715#[allow(clippy::too_many_arguments)]
716async fn send_action(
717    index: usize,
718    to: &EndpointRef,
719    body: Option<&Value>,
720    headers: Option<&BTreeMap<String, Value>>,
721    method: &str,
722    expect_reply: Option<&Expectation>,
723    router: &PartnerRouter,
724    vars: &ScenarioVars,
725    send_deadline: Duration,
726) -> Result<(), ScenarioFailure> {
727    let declared = to.endpoint.as_str();
728    let interpolated = resolve_placeholders(declared, vars)?;
729    let body = body
730        .map(|value| interpolate_value(value, vars))
731        .transpose()?;
732    let headers = headers
733        .map(|map| -> Result<BTreeMap<String, Value>, ScenarioFailure> {
734            map.iter()
735                .map(|(name, value)| Ok((name.clone(), interpolate_value(value, vars)?)))
736                .collect()
737        })
738        .transpose()?;
739    let msg = OutgoingMessage {
740        body: body.unwrap_or(Value::Null),
741        headers: headers.unwrap_or_default(),
742        method: method.to_string(),
743    };
744    let bounded =
745        tokio::time::timeout(send_deadline, router.send(declared, &interpolated, msg)).await;
746    let sent = bounded.map_err(|_| ScenarioFailure::ActionTransport {
747        action: index,
748        source: TransportError::Deadline {
749            after: send_deadline,
750        },
751    })?;
752    let reply = sent.map_err(|source| {
753        // Render-site defense: the http lane pre-renders its
754        // composite "key path" overflow form with each half
755        // redacted, but a third-party adapter may hand the overflow
756        // over RAW; the runner holds the secret set. Only the exact
757        // pre-rendered shape (one space, path half leading `/`)
758        // redacts per half — one pass over that shape would merge
759        // two query-bearing halves into a single pair and swallow
760        // the path half. Any other string, including a raw
761        // third-party key, redacts as ONE value: splitting an
762        // arbitrary string could sever a secret value across halves
763        // and print its tail (fail-safe, idempotent, ADR-0051).
764        let source = match source {
765            TransportError::LaneFifoOverflow { lane_key, bound } => {
766                let rendered = match lane_key.split_once(' ') {
767                    Some((key_half, path_half))
768                        if !key_half.contains(' ') && path_half.starts_with('/') =>
769                    {
770                        format!(
771                            "{} {}",
772                            redact_wire_path(key_half, &router.secret_query_keys()),
773                            redact_wire_path(path_half, &router.secret_query_keys())
774                        )
775                    }
776                    _ => redact_wire_path(&lane_key, &router.secret_query_keys()),
777                };
778                TransportError::LaneFifoOverflow {
779                    lane_key: rendered,
780                    bound,
781                }
782            }
783            other => other,
784        };
785        ScenarioFailure::ActionTransport {
786            action: index,
787            source,
788        }
789    })?;
790    if let Some(expectation) = expect_reply {
791        let Some(reply) = reply else {
792            // Fail closed: the grammar promised a direct reply, but
793            // the adapter produced none — an apparatus defect, never
794            // a silently-skipped assertion.
795            return Err(ScenarioFailure::ActionTransport {
796                action: index,
797                source: TransportError::Other {
798                    message: "direct send produced no reply".to_string(),
799                },
800            });
801        };
802        let value = reply_body_value(&reply);
803        if !expectation_matches(expectation, &value) {
804            return Err(ScenarioFailure::ValidationMismatch {
805                action: index,
806                detail: format!(
807                    "direct reply on {}: expected {}, got {}",
808                    to.endpoint,
809                    render_expectation(expectation),
810                    stringify(&value)
811                ),
812            });
813        }
814    }
815    Ok(())
816}
817
818/// Converts a synchronous `direct:` reply exchange's body into the
819/// matcher value an `expectReply` assertion reads (rc-qvz6): the
820/// reply message is the exchange's output when the route produced
821/// one, the (route-mutated — `set_body` writes it) input otherwise.
822/// Feature-free by design: the partner-body extractors stay
823/// `http`-gated; this path never touches the wire. Crate-visible for
824/// the runner's unit tests, like the interpolation primitives.
825pub(crate) fn reply_body_value(exchange: &Exchange) -> Value {
826    let message = exchange.output.as_ref().unwrap_or(&exchange.input);
827    match &message.body {
828        Body::Json(value) => value.clone(),
829        Body::Text(text) => reply_bytes_value(text.as_bytes()),
830        Body::Xml(text) => reply_bytes_value(text.as_bytes()),
831        Body::Bytes(bytes) => reply_bytes_value(bytes),
832        // Empty and consumed-stream bodies carry no reply bytes, and
833        // foreign `#[non_exhaustive]` body kinds (none today) expose
834        // none either; the value reads as the empty string.
835        _ => Value::String(String::new()),
836    }
837}
838
839/// Parses reply bytes as JSON, falling back to a lossy-UTF-8 string
840/// when they are not JSON text: a text body holding JSON is observed
841/// as the structured value the matcher verbs expect, and any other
842/// text stays textual. Shared with the sql validate executor, whose
843/// blob cells obey the same law.
844pub(crate) fn reply_bytes_value(bytes: &[u8]) -> Value {
845    serde_json::from_slice(bytes)
846        .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(bytes).into_owned()))
847}
848
849/// Renders an expectation for an `expectReply` mismatch detail: the
850/// verb and its payload in the document grammar's own terms.
851fn render_expectation(expectation: &Expectation) -> String {
852    match expectation {
853        Expectation::Equals(expected) => format!("equals {expected}"),
854        Expectation::Regex(pattern) => format!("matches regex `{pattern}`"),
855        Expectation::Contains(needle) => format!("contains `{needle}`"),
856        Expectation::StartsWith(prefix) => format!("startsWith `{prefix}`"),
857        Expectation::EndsWith(suffix) => format!("endsWith `{suffix}`"),
858        Expectation::Exists => "exists".to_string(),
859        Expectation::JsonSubset(pattern) => format!("is a superset of {pattern}"),
860        // Foreign `#[non_exhaustive]` variants (none today): no verb
861        // renders, but the matcher already failed closed.
862        _ => "the expected value".to_string(),
863    }
864}
865
866/// Awaits a `receive` action until the deadline, records the message,
867/// and applies `extract` into `vars`.
868async fn receive_action(
869    index: usize,
870    from: &EndpointRef,
871    deadline: Duration,
872    extract: Option<&BTreeMap<String, String>>,
873    router: &PartnerRouter,
874    vars: &mut ScenarioVars,
875) -> Result<(), ScenarioFailure> {
876    // The lane is read under the two-key contract: the declared
877    // string names the registered lane when it can, and the
878    // interpolated URI resolves a dynamic reference's lane by
879    // authority (`lane_key_for`).
880    let declared = from.endpoint.as_str();
881    let interpolated = resolve_placeholders(declared, vars)?;
882    let message = router
883        .receive(declared, &interpolated, deadline)
884        .await
885        .map_err(|source| {
886            // Render-site defense: a third-party adapter may hand
887            // over RAW endpoint and lane evidence; the runner holds
888            // the secret set, and redaction is idempotent on
889            // already-masked output (ADR-0051).
890            let keys = router.secret_query_keys();
891            match source {
892                ReceiveError::Timeout(timeout) => ScenarioFailure::ReceiveTimeout {
893                    endpoint: redact_wire_path(&timeout.endpoint, &keys),
894                    deadline,
895                    lanes: lanes_suffix(
896                        &timeout
897                            .lanes_recorded
898                            .iter()
899                            .map(|lane| redact_wire_path(lane, &keys))
900                            .collect::<Vec<_>>(),
901                    ),
902                },
903                ReceiveError::Overflow(overflow) => ScenarioFailure::ArrivalLaneOverflow {
904                    endpoint: redact_wire_path(&overflow.endpoint, &keys),
905                    dropped: overflow.dropped,
906                },
907                ReceiveError::Transport(source) => ScenarioFailure::ActionTransport {
908                    action: index,
909                    source,
910                },
911            }
912        })?;
913    if let Some(extract) = extract {
914        for (name, selector) in extract {
915            let value = select_from(&message, selector).ok_or_else(|| {
916                ScenarioFailure::ValidationMismatch {
917                    action: index,
918                    detail: format!(
919                        "extract of `{selector}` into variable `{name}` resolved to nothing"
920                    ),
921                }
922            })?;
923            vars.set(name.clone(), value);
924        }
925    }
926    vars.remember(from.endpoint.clone(), message);
927    Ok(())
928}
929
930/// Evaluates a `validate` action (ADR-0069 §5).
931///
932/// The `partner` target asserts the exact filtered count of the
933/// requests the harness partner recorded, read as the router's
934/// snapshot — one immediate read without a deadline, a polled one
935/// with it ([`partner_validate_action`]). The `sql` target asserts
936/// the doc-authored read's row shape against the named datasource's
937/// pool through [`sql_validate_action`], which owns the deadline
938/// poll (SQL state is non-monotone, so its lattice differs from the
939/// partner's). Every other target applies the message grammar
940/// against `vars`; the deadline is partner/sql-only (the grammar
941/// rejected it on these targets at parse time, so the message arm
942/// ignores it). An `elapsedAtLeast` bound on a `lastReceived` target
943/// checks the message's wire arrival against the scenario-start
944/// anchor before the grammar runs; the grammar rejected it on every
945/// other target at parse time. Mismatch details name the validation
946/// subject — the variable's name, the receiving endpoint, or the
947/// partner URI — so a corrupted-header regression is diagnosable
948/// from the failure text.
949async fn validate_action(
950    action: &ScenarioAction,
951    index: usize,
952    started_at: Instant,
953    router: &PartnerRouter,
954    vars: &ScenarioVars,
955    datasource_catalog: Option<&Arc<dyn DatasourceCatalog>>,
956) -> Result<(), ScenarioFailure> {
957    // run_action dispatches only the Validate variant here; the
958    // fallback mirrors the impossible pairing arms below.
959    let ScenarioAction::Validate {
960        target,
961        expectation,
962        deadline,
963        elapsed_at_least,
964    } = action
965    else {
966        return Err(unpaired_validate(index));
967    };
968    match (target, expectation) {
969        // The parser pairs a `partner` target with the partner count
970        // grammar; this arm reads the router's snapshot and owns the
971        // deadline.
972        (ScenarioTarget::Partner(endpoint), ValidateExpectation::Partner(expected)) => {
973            partner_validate_action(index, &endpoint.endpoint, expected, *deadline, router).await
974        }
975        // The parser pairs a `sql` target with the row-shape grammar;
976        // this arm reads the datasource's live state and owns the
977        // deadline poll. A `None` catalog fails closed inside (the
978        // sql-action precedent), so a scenario never silently skips
979        // its assertion.
980        (ScenarioTarget::Sql(target), ValidateExpectation::Rows(expected)) => {
981            sql_validate_action(index, target, expected, *deadline, datasource_catalog).await
982        }
983        (_, ValidateExpectation::Message(expectation)) => {
984            let (value, subject) = match target {
985                ScenarioTarget::LastReceived(endpoint) => {
986                    // The declared endpoint may carry query bytes: the
987                    // subject renders redacted like every diagnostic
988                    // that quotes a wire path (ADR-0051).
989                    let redacted =
990                        redact_wire_path(&endpoint.endpoint, &router.secret_query_keys());
991                    let message = vars.last_received(&endpoint.endpoint).ok_or_else(|| {
992                        ScenarioFailure::ValidationMismatch {
993                            action: index,
994                            detail: format!(
995                                "no message has been received on {redacted} to validate"
996                            ),
997                        }
998                    })?;
999                    // The elapsed bound anchors to the message's wire
1000                    // arrival, never the consumption time: a message
1001                    // consumed late can still have arrived early (the
1002                    // wire is the proof, ADR-0069 §5).
1003                    if let Some(bound) = elapsed_at_least {
1004                        let actual = message
1005                            .arrival
1006                            .checked_duration_since(started_at)
1007                            .unwrap_or_default();
1008                        if actual < *bound {
1009                            return Err(ScenarioFailure::ValidationMismatch {
1010                                action: index,
1011                                detail: format!(
1012                                    "{redacted}: arrived {} after the scenario started; `elapsedAtLeast` requires {}",
1013                                    humantime::format_duration(actual),
1014                                    humantime::format_duration(*bound)
1015                                ),
1016                            });
1017                        }
1018                    }
1019                    (
1020                        message.body.clone(),
1021                        format!("body last received on {redacted}"),
1022                    )
1023                }
1024                ScenarioTarget::Variable(name) => (
1025                    vars.get(name)
1026                        .cloned()
1027                        .ok_or_else(|| ScenarioFailure::VarUnresolved { name: name.clone() })?,
1028                    format!("variable `{name}`"),
1029                ),
1030                // Taken by the arm above: the grammar never pairs a
1031                // `partner` target with the message expectation.
1032                ScenarioTarget::Partner(_) => return Err(unpaired_validate(index)),
1033                // Taken by the arm above: the grammar pairs a `sql`
1034                // target with the rows grammar only; a message
1035                // expectation here means a caller bypassed the
1036                // parser.
1037                ScenarioTarget::Sql(_) => return Err(unpaired_validate(index)),
1038            };
1039            // The per-form booleans delegate to the shared core
1040            // (`camel_matchers::expectation_matches`); the detail
1041            // strings stay here, where subject rendering and
1042            // redaction live.
1043            match expectation {
1044                Expectation::Equals(expected) => check(
1045                    index,
1046                    expectation_matches(expectation, &value),
1047                    format!("{subject}: expected {expected}, got {value}"),
1048                ),
1049                // The parser pre-verifies regex patterns at load time,
1050                // so the invalid-regex arm is unreachable through the
1051                // harness; it stays for the byte-identical verdicts,
1052                // short-circuiting before the core delegation (core's
1053                // Regex arm returns false on compile-fail).
1054                Expectation::Regex(pattern) => {
1055                    if let Err(error) = regex::Regex::new(pattern) {
1056                        return Err(ScenarioFailure::ValidationMismatch {
1057                            action: index,
1058                            detail: format!("invalid regex `{pattern}`: {error}"),
1059                        });
1060                    }
1061                    check(
1062                        index,
1063                        expectation_matches(expectation, &value),
1064                        format!("{subject}: `{pattern}` did not match {value}"),
1065                    )
1066                }
1067                Expectation::Contains(needle) => check(
1068                    index,
1069                    expectation_matches(expectation, &value),
1070                    format!("{subject}: did not contain `{needle}`: {value}"),
1071                ),
1072                Expectation::StartsWith(prefix) => check(
1073                    index,
1074                    expectation_matches(expectation, &value),
1075                    format!("{subject}: did not start with `{prefix}`: {value}"),
1076                ),
1077                Expectation::EndsWith(suffix) => check(
1078                    index,
1079                    expectation_matches(expectation, &value),
1080                    format!("{subject}: did not end with `{suffix}`: {value}"),
1081                ),
1082                Expectation::Exists => check(
1083                    index,
1084                    expectation_matches(expectation, &value),
1085                    format!("{subject}: expected a value, got null"),
1086                ),
1087                Expectation::JsonSubset(pattern) => check(
1088                    index,
1089                    expectation_matches(expectation, &value),
1090                    format!("{subject}: not a superset of {pattern}: {value}"),
1091                ),
1092                // Foreign `#[non_exhaustive]` variants (none today):
1093                // the harness has no matcher for them, so they fail
1094                // closed.
1095                _ => Err(ScenarioFailure::ValidationMismatch {
1096                    action: index,
1097                    detail: "validate expectation kind is not supported by the message grammar"
1098                        .to_string(),
1099                }),
1100            }
1101        }
1102        // The parser never pairs a partner or sql target with another
1103        // kind's grammar, and never a rows expectation with a
1104        // non-sql target: a `partner` target pairs with the partner
1105        // count grammar, a `sql` target with the rows grammar
1106        // (`rows` or a count bound), every other target with the
1107        // message grammar.
1108        _ => Err(unpaired_validate(index)),
1109    }
1110}
1111
1112/// The failure for a target/expectation pairing the grammar never
1113/// produces: the parser pairs `partner` targets with the partner
1114/// count grammar, `sql` targets with the rows grammar, and every
1115/// other target with the message grammar, so only a caller bypassing
1116/// the parser reaches these arms.
1117fn unpaired_validate(index: usize) -> ScenarioFailure {
1118    ScenarioFailure::ValidationMismatch {
1119        action: index,
1120        detail: "validate target kind does not pair with the expectation kind: `partner` pairs \
1121                 with the partner count grammar, `sql` with the rows grammar, and every other \
1122                 target with the message grammar"
1123            .to_string(),
1124    }
1125}
1126
1127/// Turns a validation predicate into a [`ScenarioFailure`] on `false`.
1128fn check(index: usize, passed: bool, detail: String) -> Result<(), ScenarioFailure> {
1129    if passed {
1130        Ok(())
1131    } else {
1132        Err(ScenarioFailure::ValidationMismatch {
1133            action: index,
1134            detail,
1135        })
1136    }
1137}
1138
1139/// Reads a value out of a received message by dotted selector.
1140///
1141/// Grammar: the first segment selects `body`, `headers`, `status`,
1142/// `method`, or `path`; the rest is a literal header name
1143/// (`headers.X-Id`, dots allowed in the name) or a dotted object path
1144/// under the body (`body.user.id`). A bare `body` or `headers` selects
1145/// the whole part.
1146///
1147/// Header lookup is ASCII-case-insensitive: adapters differ in header
1148/// casing (hyper lowercases wire names; the fake preserves author
1149/// casing), and the same selector must behave identically per adapter.
1150/// Wire recording stays lowercase.
1151fn select_from(message: &IncomingMessage, selector: &str) -> Option<Value> {
1152    let (head, rest) = match selector.split_once('.') {
1153        Some((head, rest)) => (head, Some(rest)),
1154        None => (selector, None),
1155    };
1156    match head {
1157        "body" => match rest {
1158            None => Some(message.body.clone()),
1159            Some(path) => walk_path(&message.body, path),
1160        },
1161        "headers" => match rest {
1162            None => Some(Value::Object(
1163                message
1164                    .headers
1165                    .iter()
1166                    .map(|(name, value)| (name.clone(), value.clone()))
1167                    .collect(),
1168            )),
1169            Some(name) => lookup_header(&message.headers, name),
1170        },
1171        // The transport-scalar heads carry no sub-path: `status.why` is
1172        // not part of the grammar and resolves to nothing.
1173        "status" if rest.is_none() => Some(
1174            message
1175                .status
1176                .map_or(Value::Null, |code| Value::Number(code.into())),
1177        ),
1178        "method" if rest.is_none() => {
1179            Some(message.method.clone().map_or(Value::Null, Value::String))
1180        }
1181        "path" if rest.is_none() => Some(message.path.clone().map_or(Value::Null, Value::String)),
1182        _ => None,
1183    }
1184}
1185
1186/// Case-insensitive header lookup: the first header whose name matches
1187/// the selector ASCII-case-insensitively wins; header maps are
1188/// case-unique per adapter, so the fold is deterministic.
1189fn lookup_header(headers: &BTreeMap<String, Value>, name: &str) -> Option<Value> {
1190    headers
1191        .iter()
1192        .find(|(key, _)| key.eq_ignore_ascii_case(name))
1193        .map(|(_, value)| value.clone())
1194}
1195
1196/// Walks a dotted object path under a body value; arrays and scalars
1197/// resolve to nothing.
1198fn walk_path(value: &Value, path: &str) -> Option<Value> {
1199    let mut current = value;
1200    for key in path.split('.') {
1201        current = current.as_object()?.get(key)?;
1202    }
1203    Some(current.clone())
1204}