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