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