Skip to main content

featherbit/graph/
engine.rs

1//! Policy compilation and graph execution.
2//!
3//! Turns a [`PolicyConfig`] (nodes + `from`/`to` edges from `gateway.yaml`)
4//! into a [`CompiledGraph`] with instantiated plugins, then walks that graph
5//! per request: success edges on `Ok`, error edges (or the policy-level
6//! catch-all handler) on failure, stopping at terminal `client` nodes.
7
8use std::collections::{HashMap, HashSet};
9use std::sync::Arc;
10
11use serde::Serialize;
12
13use crate::config::{NodeConfig, PolicyConfig};
14use crate::context::Context;
15use crate::debug::{EdgeKind, StepOutcome, TraceRecorder};
16use crate::plugins::resources::PluginResources;
17use crate::plugins::{self, Plugin};
18
19/// A compiled, ready-to-execute graph with instantiated plugins.
20///
21/// Built by [`compile_policy`] and shared read-only by the data-plane; one
22/// instance serves all requests matching the route that references its policy.
23pub struct CompiledGraph {
24    nodes: HashMap<String, Box<dyn Plugin>>,
25    /// node_id -> (port -> target node_id). Ports normalized (`out` -> `success`).
26    edges: HashMap<String, HashMap<String, String>>,
27    /// The node that starts the pipeline (first node after listener)
28    entry_node_id: String,
29    /// Node IDs that are terminal (client nodes) — execution stops when we reach one
30    terminal_node_ids: HashSet<String>,
31    /// Policy-level catch-all error handler node ID
32    catch_all_handler: Option<String>,
33    /// Policy name, used as a metrics label.
34    policy_name: String,
35    /// Process-wide services (metrics registry, shared clients); per-node
36    /// metrics recording is disabled when `resources.metrics` is `None`.
37    resources: Arc<PluginResources>,
38    /// Ids of `upstream` nodes whose `success` path reaches `client` without
39    /// passing any node that reads the response body. Read by
40    /// `is_stream_capable`, consulted per node in `run`.
41    stream_capable: HashSet<String>,
42    /// Why each non-capable upstream must buffer, for operator-visible reporting.
43    buffering_reasons: Vec<BufferingReason>,
44    cache_pair_warnings: Vec<CachePairWarning>,
45    /// Every `proxy-cache` half's backend, for invalidation by pair `id`.
46    cache_targets: Vec<crate::traffic::CacheTarget>,
47}
48
49/// Records that one node on an upstream's success path forces buffering.
50///
51/// Serializes as `{"upstream": ..., "blocked_by": ..., "node_type": ...}` —
52/// the shape the Admin API's policy-validate endpoint and the MCP
53/// One half of a `proxy-cache` pair with no counterpart.
54///
55/// A lookup with no store caches nothing; a store with no lookup is never
56/// read. Both are silent no-ops rather than errors -- and both are also what
57/// a policy looks like halfway through being built -- so they are reported
58/// the way a buffering reason is rather than refused.
59#[derive(Debug, Clone, PartialEq, Serialize)]
60pub struct CachePairWarning {
61    /// The shared `id` that links a pair.
62    pub cache_id: String,
63    /// The half that is present.
64    pub present_node_id: String,
65    /// The half that is missing: `"lookup"` or `"store"`.
66    pub missing_role: String,
67}
68
69/// `validate_policy` tool both report to operators/agents.
70#[derive(Debug, Clone, PartialEq, Serialize)]
71pub struct BufferingReason {
72    #[serde(rename = "upstream")]
73    pub upstream_node_id: String,
74    #[serde(rename = "blocked_by")]
75    pub blocked_by_node_id: String,
76    pub node_type: String,
77}
78
79// Manual impl: `Box<dyn Plugin>` doesn't implement `Debug`, so `#[derive]`
80// isn't available. This exists solely so `Result<CompiledGraph, String>` can
81// be `.unwrap_err()`'d in tests; the node table is summarized by id only.
82impl std::fmt::Debug for CompiledGraph {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("CompiledGraph")
85            .field("policy_name", &self.policy_name)
86            .field("node_ids", &self.nodes.keys().collect::<Vec<_>>())
87            .field("edges", &self.edges)
88            .field("entry_node_id", &self.entry_node_id)
89            .field("terminal_node_ids", &self.terminal_node_ids)
90            .field("catch_all_handler", &self.catch_all_handler)
91            .finish()
92    }
93}
94
95impl CompiledGraph {
96    /// Executes the graph with the given initial context and returns the
97    /// final context (with `response` populated).
98    ///
99    /// Walks nodes starting at the entry node (the first node after the
100    /// listener), following the success edge after each `Ok` result. On a
101    /// plugin error the error is tagged with the failing node's ID, pushed
102    /// onto `ctx.errors`, and execution jumps to the node's `error` edge if
103    /// one exists, otherwise to the policy-level catch-all handler; with
104    /// neither, execution stops and a generic JSON 500 response is written.
105    /// Reaching a terminal `client` node (or a node with no success edge)
106    /// ends the walk. This method never fails: every outcome is expressed
107    /// through the returned context's response.
108    pub async fn execute(&self, ctx: Context) -> Context {
109        self.run(ctx, None).await.0
110    }
111
112    /// Same walk as [`execute`](Self::execute), additionally recording a
113    /// [`Trace`] of every node: the context after each one, the outcome, and
114    /// which edge the engine followed.
115    ///
116    /// Used by debug mode and the sandbox. Deliberately the *same* loop rather
117    /// than a parallel implementation — a trace that could drift from the real
118    /// execution path would be worse than no trace at all.
119    pub async fn execute_traced(
120        &self,
121        ctx: Context,
122        recorder: TraceRecorder,
123    ) -> (Context, TraceRecorder) {
124        let (ctx, rec) = self.run(ctx, Some(recorder)).await;
125        (
126            ctx,
127            rec.expect("recorder is returned when one was supplied"),
128        )
129    }
130
131    /// The single graph walk, optionally recording each step.
132    ///
133    /// When `recorder` is `None` the only added cost is one `Option`
134    /// discriminant check per node — the same shape as the existing metrics
135    /// branch, and perfectly branch-predicted.
136    async fn run(
137        &self,
138        mut ctx: Context,
139        mut recorder: Option<TraceRecorder>,
140    ) -> (Context, Option<TraceRecorder>) {
141        let mut current_node_id = self.entry_node_id.clone();
142
143        loop {
144            let node = match self.nodes.get(&current_node_id) {
145                Some(n) => n,
146                None => {
147                    ctx.response.status_code = 500;
148                    ctx.response.body = bytes::Bytes::from(format!(
149                        "Graph error: node '{}' not found",
150                        current_node_id
151                    ));
152                    // Same rule as the no-handler 500 fallback and
153                    // `ErrorHandlerPlugin::execute`: this overwrites
154                    // `response.body`, so a stream left by an earlier node
155                    // (reachable here because `compile_policy` validates only
156                    // `from_node`, never `to_node` — see
157                    // `infer_stream_capability`'s doc below) must not survive
158                    // alongside it.
159                    ctx.response.stream = None;
160                    if let Some(r) = recorder.as_mut() {
161                        r.record_step(
162                            &current_node_id,
163                            "<missing>",
164                            StepOutcome::Error {
165                                code: "NODE_NOT_FOUND".to_string(),
166                                message: format!("node '{}' not found", current_node_id),
167                            },
168                            std::time::Duration::ZERO,
169                            EdgeKind::NodeNotFound,
170                            None,
171                            None,
172                            &ctx,
173                        );
174                    }
175                    break;
176                }
177            };
178
179            let node_type = node.plugin_type().to_string();
180            // Reserved `context.message` key, same mechanism as `__policy`,
181            // `__route`, and `__request_start_ms` (set by the listener before
182            // the graph runs; see `src/server/listener.rs`): tells a
183            // stream-capable `upstream` node it may hand back a
184            // `response.stream` instead of buffering.
185            //
186            // Written unconditionally (never merely inserted when `true`) on
187            // every node, capable or not: `ctx`/`ctx.message` survives an
188            // error-port hop, so on a failover shape (a capable `upstream`'s
189            // `error` port routed to a non-capable one) a stale `true` left
190            // by the failed node would otherwise still be sitting in
191            // `ctx.message` when the non-capable node runs, telling it to
192            // stream when it must not — silently bypassing whatever reads
193            // the buffered body downstream of it (a `response-rewrite`, a
194            // logger). Always overwriting with this node's own capability
195            // closes that: a non-capable node always sees `false`.
196            ctx.message.insert(
197                "__may_stream".to_string(),
198                serde_json::json!(self.is_stream_capable(&current_node_id)),
199            );
200            let started = std::time::Instant::now();
201            let result = node.execute(ctx).await;
202            let elapsed = started.elapsed();
203            if let Some(ref m) = self.resources.metrics {
204                m.node_execution_count
205                    .with_label_values(&[&self.policy_name, &current_node_id, &node_type])
206                    .inc();
207                m.node_execution_duration
208                    .with_label_values(&[&self.policy_name, &current_node_id])
209                    .observe(elapsed.as_secs_f64());
210            }
211
212            match result {
213                Ok(output) => {
214                    ctx = output.context;
215                    let port = output.port.unwrap_or("success");
216                    // `error` is reachable only through `Err`, which records a
217                    // GatewayError and follows the error/catch-all fallback
218                    // chain. An `Ok` naming it would silently take an error
219                    // edge with no error record attached.
220                    debug_assert!(
221                        port != "error",
222                        "plugins must not emit on the error port via Ok"
223                    );
224
225                    // If this is a terminal node (client), we're done
226                    let terminal = self.terminal_node_ids.contains(&current_node_id);
227                    let next = if terminal {
228                        None
229                    } else {
230                        self.edges.get(&current_node_id).and_then(|m| m.get(port))
231                    };
232                    if let Some(r) = recorder.as_mut() {
233                        let edge = if terminal {
234                            EdgeKind::Terminal
235                        } else if next.is_none() {
236                            EdgeKind::EndOfChain // defensive: validation makes this unreachable
237                        } else if port == "success" {
238                            EdgeKind::Success
239                        } else {
240                            EdgeKind::Outcome
241                        };
242                        r.record_step(
243                            &current_node_id,
244                            &node_type,
245                            StepOutcome::Success,
246                            elapsed,
247                            edge,
248                            (port != "success").then_some(port),
249                            next.map(String::as_str),
250                            &ctx,
251                        );
252                    }
253                    match next {
254                        Some(next_id) => current_node_id = next_id.clone(),
255                        // Terminal, or no edge for this port — end of chain.
256                        None => break,
257                    }
258                }
259                Err(mut err) => {
260                    if let Some(ref m) = self.resources.metrics {
261                        m.node_errors
262                            .with_label_values(&[
263                                &self.policy_name,
264                                &current_node_id,
265                                &err.error.code,
266                            ])
267                            .inc();
268                    }
269
270                    // Tag the error with the node_id that produced it
271                    err.error.node_id = current_node_id.clone();
272                    // Operational visibility: an error-port exit is the only
273                    // signal an operator gets without debug traces, so it is
274                    // logged here regardless of which edge picks it up.
275                    tracing::warn!(
276                        "policy '{}': node '{}' exited on error port: {} ({})",
277                        self.policy_name,
278                        current_node_id,
279                        err.error.code,
280                        err.error.message
281                    );
282                    let outcome = StepOutcome::Error {
283                        code: err.error.code.clone(),
284                        message: err.error.message.clone(),
285                    };
286                    err.context.errors.push(err.error);
287                    ctx = err.context;
288
289                    // Try per-node error edge first, then the policy catch-all.
290                    let next_error = self
291                        .edges
292                        .get(&current_node_id)
293                        .and_then(|m| m.get("error"))
294                        .map(|id| (id.clone(), EdgeKind::Error))
295                        .or_else(|| {
296                            self.catch_all_handler
297                                .as_ref()
298                                .map(|id| (id.clone(), EdgeKind::CatchAll))
299                        });
300
301                    if next_error.is_none() {
302                        // No error handling — return 500
303                        ctx.response.status_code = 500;
304                        ctx.response.body = bytes::Bytes::from(
305                            r#"{"error": "internal_error", "message": "Unhandled error in routing policy"}"#,
306                        );
307                        // This overwrites `response.body`; a stream set by an
308                        // earlier node in the same chain (e.g. `upstream`)
309                        // must not survive alongside it — see
310                        // `ErrorHandlerPlugin::execute` for the same rule and
311                        // why it matters (the listener treats a set stream as
312                        // authoritative over `body`).
313                        ctx.response.stream = None;
314                        ctx.response.headers.insert(
315                            "content-type".to_string(),
316                            vec!["application/json".to_string()],
317                        );
318                    }
319
320                    if let Some(r) = recorder.as_mut() {
321                        let (edge, next_id) = match &next_error {
322                            Some((id, kind)) => (*kind, Some(id.as_str())),
323                            None => (EdgeKind::Unhandled, None),
324                        };
325                        r.record_step(
326                            &current_node_id,
327                            &node_type,
328                            outcome,
329                            elapsed,
330                            edge,
331                            None,
332                            next_id,
333                            &ctx,
334                        );
335                    }
336
337                    match next_error {
338                        Some((id, _)) => current_node_id = id,
339                        None => break,
340                    }
341                }
342            }
343        }
344
345        (ctx, recorder)
346    }
347
348    /// Whether the `upstream` node named `node_id` may stream its response
349    /// body straight through to the client — nothing between it and `client`
350    /// on the `success` path reads the buffered response body. Consulted by
351    /// `run` to set the `__may_stream` reserved key before invoking the node.
352    pub fn is_stream_capable(&self, node_id: &str) -> bool {
353        self.stream_capable.contains(node_id)
354    }
355
356    /// Why each non-stream-capable `upstream` node must buffer, one entry per
357    /// blocked upstream, for operator-visible reporting.
358    /// `proxy-cache` halves with no counterpart. Informational, like
359    /// [`CompiledGraph::buffering_reasons`].
360    pub fn cache_pair_warnings(&self) -> &[CachePairWarning] {
361        &self.cache_pair_warnings
362    }
363
364    /// Every `proxy-cache` half's backend, for invalidation by pair `id`.
365    pub fn cache_targets(&self) -> &[crate::traffic::CacheTarget] {
366        &self.cache_targets
367    }
368
369    pub fn buffering_reasons(&self) -> &[BufferingReason] {
370        &self.buffering_reasons
371    }
372}
373
374/// Compiles a [`PolicyConfig`] into a ready-to-execute [`CompiledGraph`].
375///
376/// Instantiates each node's plugin via `plugins::create_plugin`, records
377/// `client` nodes as terminals, and indexes edges per node by source port
378/// (`out` normalizes to `success`). The entry node is the target of the
379/// listener's success/out edge. Fails if the policy has no `listener` node,
380/// a plugin cannot be constructed, an edge names a port its node type does
381/// not declare, two edges leave the same `node.port` (fan-out is not
382/// supported), or a node's `success`/outcome port ([`PortKind::Success`] or
383/// [`PortKind::Outcome`]) has no outgoing edge — `error` ports are exempt
384/// (fallback chain: per-node error edge -> policy catch-all -> default 500).
385///
386/// [`PortKind::Success`]: crate::plugins::ports::PortKind::Success
387/// [`PortKind::Outcome`]: crate::plugins::ports::PortKind::Outcome
388///
389/// Edge endpoints use the `node_id.port` form:
390///
391/// ```yaml
392/// edges:
393///   - from: listener.out
394///     to: rewrite.in
395///   - from: rewrite.success
396///     to: client.in
397///   - from: rewrite.error
398///     to: error-handler.in
399/// ```
400pub fn compile_policy(
401    policy: &PolicyConfig,
402    resources: Arc<PluginResources>,
403) -> Result<CompiledGraph, String> {
404    // Before anything is constructed: this reads config only, and running it
405    // first means a split pair is reported as a split pair rather than as
406    // whatever its half happens to fail on downstream.
407    let cache_pair_warnings = validate_cache_pairs(&policy.nodes)?;
408
409    let mut nodes: HashMap<String, Box<dyn Plugin>> = HashMap::new();
410    let mut listener_node_id = None;
411    let mut terminal_node_ids = HashSet::new();
412
413    // Instantiate all nodes
414    for node_config in &policy.nodes {
415        tracing::debug!(
416            "Compiling node '{}' type='{}' config={:?}",
417            node_config.id,
418            node_config.node_type,
419            node_config.config
420        );
421        // Interpolate `${ENV_VAR}` in the node config before instantiating the
422        // plugin. This is the source-agnostic choke point for every config
423        // source — gateway.yaml (loaded raw), the Web UI / Admin API, and etcd
424        // all deliver placeholder-form config; only the compiled graph ever
425        // holds resolved values, so the stored config the Admin API serves
426        // never contains resolved secrets.
427        let mut config = node_config.config.clone();
428        for value in config.values_mut() {
429            crate::config::interpolate_env_json(value);
430        }
431        let plugin = plugins::create_plugin(&node_config.node_type, &config, &resources)?;
432        if node_config.node_type == "listener" {
433            listener_node_id = Some(node_config.id.clone());
434        }
435        if node_config.node_type == "client" {
436            terminal_node_ids.insert(node_config.id.clone());
437        }
438        nodes.insert(node_config.id.clone(), plugin);
439    }
440
441    let listener_node_id = listener_node_id.ok_or("Policy must have a listener node")?;
442
443    let node_types: HashMap<String, String> = policy
444        .nodes
445        .iter()
446        .map(|n| (n.id.clone(), n.node_type.clone()))
447        .collect();
448
449    // Parse edges, indexed by source port. `out` normalizes to `success`.
450    let mut edges: HashMap<String, HashMap<String, String>> = HashMap::new();
451    for edge in &policy.edges {
452        let (from_node, from_port) = parse_edge_endpoint(&edge.from)?;
453        let (to_node, _to_port) = parse_edge_endpoint(&edge.to)?;
454        let from_port = if from_port == "out" {
455            "success".to_string()
456        } else {
457            from_port
458        };
459
460        let node_type = node_types.get(&from_node).ok_or_else(|| {
461            format!(
462                "policy '{}': edge references unknown node '{}'",
463                policy.name, from_node
464            )
465        })?;
466        let spec = plugins::port_spec(node_type)
467            .ok_or_else(|| format!("Unknown plugin type: {}", node_type))?;
468        if !spec.outputs.iter().any(|p| p.name == from_port) {
469            return Err(format!(
470                "policy '{}': node '{}' (type '{}') has no output port '{}'",
471                policy.name, from_node, node_type, from_port
472            ));
473        }
474        if edges
475            .entry(from_node.clone())
476            .or_default()
477            .insert(from_port.clone(), to_node)
478            .is_some()
479        {
480            return Err(format!(
481                "policy '{}': duplicate edge from '{}.{}' — fan-out is not supported",
482                policy.name, from_node, from_port
483            ));
484        }
485    }
486
487    // Mandatory wiring: every success/outcome port of every node must have an edge.
488    for node_config in &policy.nodes {
489        let spec = plugins::port_spec(&node_config.node_type)
490            .ok_or_else(|| format!("Unknown plugin type: {}", node_config.node_type))?;
491        for p in spec.outputs {
492            if matches!(p.kind, plugins::ports::PortKind::Error) {
493                continue;
494            }
495            let wired = edges
496                .get(&node_config.id)
497                .is_some_and(|m| m.contains_key(p.name));
498            if !wired {
499                return Err(format!(
500                    "policy '{}': output port '{}' of node '{}' (type '{}') must be wired — add an edge from '{}.{}'",
501                    policy.name, p.name, node_config.id, node_config.node_type, node_config.id, p.name
502                ));
503            }
504        }
505    }
506
507    // Reject cycles: the runtime walk (`run`) follows edges with no step
508    // limit, so a loop anywhere in the graph — through any port, error edges
509    // included — would never terminate. Checking each declared node for
510    // self-reachability keeps the reported node independent of HashMap
511    // iteration order.
512    for node_config in &policy.nodes {
513        let start = &node_config.id;
514        let mut stack: Vec<&String> = edges
515            .get(start)
516            .map(|m| m.values().collect())
517            .unwrap_or_default();
518        let mut seen: HashSet<&String> = HashSet::new();
519        while let Some(next) = stack.pop() {
520            if next == start {
521                return Err(format!(
522                    "policy '{}': policy graph contains a cycle through node '{}' — policies must be acyclic",
523                    policy.name, start
524                ));
525            }
526            if seen.insert(next) {
527                if let Some(m) = edges.get(next) {
528                    stack.extend(m.values());
529                }
530            }
531        }
532    }
533
534    // The entry node is the first node connected from the listener's success/out edge
535    let entry_node_id = edges
536        .get(&listener_node_id)
537        .and_then(|m| m.get("success"))
538        .cloned()
539        .unwrap_or_else(|| listener_node_id.clone());
540
541    // Compile-time streaming inference: see `infer_stream_capability` for the
542    // walk itself; it is a free function (rather than inlined here) so tests
543    // can drive it directly with hand-built node/edge maps, the same way
544    // `failing_graph`/`outcome_graph` below already do for other engine
545    // behaviour.
546    let (stream_capable, buffering_reasons) =
547        infer_stream_capability(&policy.nodes, &nodes, &edges);
548
549    for reason in &buffering_reasons {
550        tracing::info!(
551            policy = %policy.name,
552            upstream = %reason.upstream_node_id,
553            blocked_by = %reason.blocked_by_node_id,
554            node_type = %reason.node_type,
555            "response buffering: upstream cannot stream because a downstream node reads the response body"
556        );
557    }
558
559    let cache_targets: Vec<_> = nodes.values().filter_map(|n| n.cache_target()).collect();
560
561    Ok(CompiledGraph {
562        nodes,
563        edges,
564        entry_node_id,
565        terminal_node_ids,
566        catch_all_handler: policy.error_handler.clone(),
567        policy_name: policy.name.clone(),
568        resources,
569        stream_capable,
570        buffering_reasons,
571        cache_pair_warnings,
572        cache_targets,
573    })
574}
575
576/// Checks that every `proxy-cache` pair agrees about where it caches.
577///
578/// The two halves of a pair are linked only by their shared `id`; nothing
579/// else ties them together. If they disagree about `policy` or `store`, the
580/// store half writes somewhere the lookup half never reads, and the route
581/// compiles, serves traffic, and returns a permanent 100% miss with no error
582/// anywhere -- it simply looks like a cache that is never warm. There is no
583/// configuration for which that is correct, so it is refused.
584///
585/// A half with no counterpart is returned as a warning instead: equally
586/// useless, but also what a policy looks like halfway through being built.
587fn validate_cache_pairs(policy_nodes: &[NodeConfig]) -> Result<Vec<CachePairWarning>, String> {
588    /// One half, as configured.
589    struct Half<'a> {
590        cache_id: &'a str,
591        node_id: &'a str,
592        role: &'a str,
593        policy: &'a str,
594        store: &'a str,
595    }
596
597    let mut halves: Vec<Half> = Vec::new();
598    for node in policy_nodes {
599        if node.node_type != "proxy-cache" {
600            continue;
601        }
602        let get = |key: &str| -> Option<&str> { node.config.get(key).and_then(|v| v.as_str()) };
603        let Some(cache_id) = get("id") else { continue };
604        // `phase` is the documented key; `role` is accepted as an alias.
605        let Some(role) = get("phase").or_else(|| get("role")) else {
606            continue;
607        };
608        halves.push(Half {
609            node_id: &node.id,
610            role,
611            policy: get("policy").unwrap_or("local"),
612            store: get("store").unwrap_or(""),
613            cache_id,
614        });
615    }
616
617    let mut warnings = Vec::new();
618    let ids: Vec<&str> = {
619        let mut seen: Vec<&str> = Vec::new();
620        for h in &halves {
621            if !seen.contains(&h.cache_id) {
622                seen.push(h.cache_id);
623            }
624        }
625        seen
626    };
627
628    for cache_id in ids {
629        let group: Vec<&Half> = halves.iter().filter(|h| h.cache_id == cache_id).collect();
630
631        // Disagreement is a hard error: whichever half is wrong, the pair can
632        // never see its own entries.
633        for pair in group.windows(2) {
634            let (a, b) = (pair[0], pair[1]);
635            for (key, left, right) in [("policy", a.policy, b.policy), ("store", a.store, b.store)]
636            {
637                if left != right {
638                    return Err(format!(
639                        "proxy-cache pair '{}': node '{}' uses {} '{}' but node '{}' uses {} '{}'.                          Both halves must agree, or the store half writes where the lookup half                          never reads and the route serves a permanent 100% miss with no error",
640                        cache_id, a.node_id, key, left, b.node_id, key, right
641                    ));
642                }
643            }
644        }
645
646        // A half with no counterpart: report, do not refuse. A group is
647        // complete only once it has both a lookup and a store -- a purge
648        // alone (nothing to purge for) is a lone half too.
649        let has_lookup = group.iter().any(|h| h.role == "lookup");
650        let has_store = group.iter().any(|h| h.role == "store");
651        if !(has_lookup && has_store) {
652            // Whatever IS present (a lone lookup, a lone store, or a purge
653            // with nothing to purge for) is reported; the first missing role
654            // names what would complete it.
655            let present = group.first().expect("groups are non-empty");
656            warnings.push(CachePairWarning {
657                cache_id: cache_id.to_string(),
658                present_node_id: present.node_id.to_string(),
659                missing_role: if !has_lookup { "lookup" } else { "store" }.to_string(),
660            });
661        }
662    }
663
664    Ok(warnings)
665}
666
667/// For each `upstream` node, walks only the `success`/outcome ports forward
668/// to see whether anything before `client` reads the response body.
669///
670/// `error` ports are never walked, on the upstream itself or on any node
671/// further along the chain: an error exit runs through the node's own error
672/// edge (unwalked here for the same reason), the policy's `error_handler`
673/// catch-all, or the engine's built-in fallbacks — none of which this walk
674/// can see.
675///
676/// This is safe only because of two separate facts, not one general
677/// guarantee:
678///
679/// - Every gateway-generated error body — `ErrorHandlerPlugin::execute`, the
680///   engine's no-handler 500 fallback, and the `NODE_NOT_FOUND` fallback,
681///   all in this crate — explicitly clears `response.stream` before writing
682///   `response.body`, so none of *those* three specific sites can leave a
683///   stale stream alongside a generated body.
684/// - An error edge can in principle route to *any* node, including one that
685///   writes `response.body` itself without going through those three sites.
686///   Nothing here walks that edge to rule it out; it is safe today only
687///   because no plugin that opts out of `reads_response_body()` (the set
688///   that can run downstream of a stream-capable `upstream` without forcing
689///   it to buffer: `client`, `opentelemetry`, `prometheus`, `proxy-rewrite`,
690///   `request-id`, `response-rewrite`, `skywalking`, `traffic-label`,
691///   `zipkin`, and `proxy-cache` in its `lookup` role only) ever returns
692///   `Err` from `execute` — every `return Err` in the first nine plugins'
693///   source is in `from_config` (construction-time validation), never in
694///   `execute`; `proxy-cache`'s `lookup` role does call a fallible backend
695///   (`ResponseCache::get`) from `execute`, but matches on the result and
696///   degrades a failure to a miss rather than propagating `Err`. This is
697///   exactly why `proxy-cache`'s `store` and `purge` roles do **not** opt
698///   out despite neither reading the response body: `store` because it
699///   reads it to cache it, and `purge` because it *can* return `Err` from
700///   `execute` on a failed backend, which this invariant forbids for an
701///   opt-out node. If a future change to any of these plugins starts
702///   erroring from `execute` where it previously didn't, this walk would
703///   not catch it, and nothing else currently enforces it either.
704///
705/// Iterates `policy_nodes` (a `Vec`), not the `nodes` map, and visits each
706/// node's outgoing ports in sorted-name order: the same precedent as the
707/// cycle check in [`compile_policy`], so the reported blocker for an
708/// unchanged policy is stable across compiles instead of depending on
709/// `HashMap` iteration order.
710///
711/// An edge whose target doesn't resolve to a real node in `nodes` is treated
712/// as blocking rather than assumed harmless — `to_node`s aren't validated
713/// against the node table the way `from_node`s are, so this walk cannot
714/// assume every edge target exists.
715fn infer_stream_capability(
716    policy_nodes: &[NodeConfig],
717    nodes: &HashMap<String, Box<dyn Plugin>>,
718    edges: &HashMap<String, HashMap<String, String>>,
719) -> (HashSet<String>, Vec<BufferingReason>) {
720    let mut stream_capable = HashSet::new();
721    let mut buffering_reasons = Vec::new();
722
723    for node_config in policy_nodes {
724        if node_config.node_type != "upstream" {
725            continue;
726        }
727        let node_id = &node_config.id;
728        let mut blocked_by: Option<(&String, &str)> = None;
729        let mut seen: HashSet<&String> = HashSet::new();
730        let mut queue: Vec<&String> = edges
731            .get(node_id)
732            .and_then(|ports| ports.get("success"))
733            .into_iter()
734            .collect();
735
736        while let Some(current) = queue.pop() {
737            if !seen.insert(current) {
738                continue;
739            }
740            let Some(p) = nodes.get(current) else {
741                // An edge whose target doesn't resolve to a real node must
742                // not be assumed harmless — treat it as blocking rather than
743                // silently marking the upstream stream-capable.
744                blocked_by = Some((current, "unknown"));
745                break;
746            };
747            if p.reads_response_body() {
748                blocked_by = Some((current, p.plugin_type()));
749                break;
750            }
751            if let Some(ports) = edges.get(current) {
752                let mut names: Vec<&String> = ports
753                    .keys()
754                    .filter(|name| name.as_str() != "error")
755                    .collect();
756                names.sort();
757                queue.extend(names.into_iter().filter_map(|name| ports.get(name)));
758            }
759        }
760
761        match blocked_by {
762            None => {
763                stream_capable.insert(node_id.clone());
764            }
765            Some((blocker, node_type)) => buffering_reasons.push(BufferingReason {
766                upstream_node_id: node_id.clone(),
767                blocked_by_node_id: blocker.clone(),
768                node_type: node_type.to_string(),
769            }),
770        }
771    }
772
773    (stream_capable, buffering_reasons)
774}
775
776/// Parses `"node_id.port"` into `(node_id, port)`, defaulting the port to
777/// `"out"` when no dot is present. Splits on the last dot, so node IDs may
778/// themselves contain dots.
779fn parse_edge_endpoint(endpoint: &str) -> Result<(String, String), String> {
780    if let Some(dot_pos) = endpoint.rfind('.') {
781        let node_id = endpoint[..dot_pos].to_string();
782        let port = endpoint[dot_pos + 1..].to_string();
783        Ok((node_id, port))
784    } else {
785        Ok((endpoint.to_string(), "out".to_string()))
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792    use crate::config::{EdgeConfig, NodeConfig, PolicyConfig};
793    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
794    use bytes::Bytes;
795
796    #[test]
797    fn test_parse_edge_endpoint() {
798        let (node, port) = parse_edge_endpoint("listener.out").unwrap();
799        assert_eq!(node, "listener");
800        assert_eq!(port, "out");
801
802        let (node, port) = parse_edge_endpoint("upstream.error").unwrap();
803        assert_eq!(node, "upstream");
804        assert_eq!(port, "error");
805
806        let (node, port) = parse_edge_endpoint("rewrite.success").unwrap();
807        assert_eq!(node, "rewrite");
808        assert_eq!(port, "success");
809    }
810
811    fn test_context(path: &str) -> Context {
812        Context {
813            request: GatewayRequest {
814                method: "GET".to_string(),
815                path: path.to_string(),
816                host: "localhost".to_string(),
817                scheme: "http".to_string(),
818                headers: HashMap::new(),
819                query_params: HashMap::new(),
820                body: Bytes::new(),
821                remote_addr: "127.0.0.1:12345".to_string(),
822                protocol: Protocol::Http1,
823            },
824            response: GatewayResponse {
825                status_code: 0,
826                headers: HashMap::new(),
827                body: Bytes::new(),
828                stream: None,
829            },
830            message: HashMap::new(),
831            errors: Vec::new(),
832        }
833    }
834
835    #[tokio::test]
836    async fn test_graph_proxy_rewrite_pipeline() {
837        let mut rewrite_config = HashMap::new();
838        rewrite_config.insert(
839            "strip_path_prefix".to_string(),
840            serde_json::Value::String("/api/v1".to_string()),
841        );
842        rewrite_config.insert(
843            "phase".to_string(),
844            serde_json::Value::String("request".to_string()),
845        );
846
847        let policy = PolicyConfig {
848            name: "test".to_string(),
849            error_handler: None,
850            nodes: vec![
851                NodeConfig {
852                    id: "listener".to_string(),
853                    node_type: "listener".to_string(),
854                    config: HashMap::new(),
855                    config_ref: None,
856                    position: None,
857                },
858                NodeConfig {
859                    id: "rewrite".to_string(),
860                    node_type: "proxy-rewrite".to_string(),
861                    config: rewrite_config,
862                    config_ref: None,
863                    position: None,
864                },
865                NodeConfig {
866                    id: "client".to_string(),
867                    node_type: "client".to_string(),
868                    config: HashMap::new(),
869                    config_ref: None,
870                    position: None,
871                },
872            ],
873            edges: vec![
874                EdgeConfig {
875                    from: "listener.out".to_string(),
876                    to: "rewrite.in".to_string(),
877                },
878                EdgeConfig {
879                    from: "rewrite.success".to_string(),
880                    to: "client.in".to_string(),
881                },
882            ],
883        };
884
885        let graph = compile_policy(&policy, PluginResources::empty()).unwrap();
886        let ctx = test_context("/api/v1/users");
887        let result = graph.execute(ctx).await;
888
889        assert_eq!(result.request.path, "/users");
890    }
891
892    #[tokio::test]
893    async fn test_graph_records_node_metrics() {
894        let policy = PolicyConfig {
895            name: "metrics-test".to_string(),
896            error_handler: None,
897            nodes: vec![
898                NodeConfig {
899                    id: "listener".to_string(),
900                    node_type: "listener".to_string(),
901                    config: HashMap::new(),
902                    config_ref: None,
903                    position: None,
904                },
905                NodeConfig {
906                    id: "client".to_string(),
907                    node_type: "client".to_string(),
908                    config: HashMap::new(),
909                    config_ref: None,
910                    position: None,
911                },
912            ],
913            edges: vec![EdgeConfig {
914                from: "listener.out".to_string(),
915                to: "client.in".to_string(),
916            }],
917        };
918
919        let metrics = Arc::new(crate::metrics::GatewayMetrics::new());
920        let graph = compile_policy(&policy, PluginResources::new(Some(metrics.clone()))).unwrap();
921        graph.execute(test_context("/test")).await;
922
923        assert_eq!(
924            metrics
925                .node_execution_count
926                .with_label_values(&["metrics-test", "client", "client"])
927                .get(),
928            1
929        );
930    }
931
932    /// The policy-level catch-all: a node whose own `error` port is unwired
933    /// falls through to the named handler, which then renders the error.
934    ///
935    /// The failing node is a real one (an `upstream` pointed at a closed port),
936    /// not a bare `listener -> error-handler` chain: since the port migration,
937    /// `error-handler` passes an **errorless** context straight through, so a
938    /// handler reached without a preceding failure has nothing to render.
939    #[tokio::test]
940    async fn test_graph_error_handler_catch_all() {
941        let policy = PolicyConfig {
942            name: "test".to_string(),
943            error_handler: Some("error-handler".to_string()),
944            nodes: vec![
945                NodeConfig {
946                    id: "listener".to_string(),
947                    node_type: "listener".to_string(),
948                    config: HashMap::new(),
949                    config_ref: None,
950                    position: None,
951                },
952                NodeConfig {
953                    id: "backend".to_string(),
954                    node_type: "upstream".to_string(),
955                    config: {
956                        let mut c = HashMap::new();
957                        // Nothing listens on port 1: connect fails instantly.
958                        c.insert(
959                            "targets".to_string(),
960                            serde_json::json!([{ "host": "127.0.0.1", "port": 1 }]),
961                        );
962                        c.insert("timeout_ms".to_string(), serde_json::json!(500));
963                        c
964                    },
965                    config_ref: None,
966                    position: None,
967                },
968                NodeConfig {
969                    id: "error-handler".to_string(),
970                    node_type: "error-handler".to_string(),
971                    config: {
972                        let mut c = HashMap::new();
973                        c.insert("status_code".to_string(), serde_json::json!(503));
974                        c.insert(
975                            "body_template".to_string(),
976                            serde_json::Value::String(r#"{"error": "{{error.code}}"}"#.to_string()),
977                        );
978                        c
979                    },
980                    config_ref: None,
981                    position: None,
982                },
983                NodeConfig {
984                    id: "client".to_string(),
985                    node_type: "client".to_string(),
986                    config: HashMap::new(),
987                    config_ref: None,
988                    position: None,
989                },
990            ],
991            edges: vec![
992                EdgeConfig {
993                    from: "listener.out".to_string(),
994                    to: "backend.in".to_string(),
995                },
996                // backend.error is deliberately unwired -> catch-all.
997                EdgeConfig {
998                    from: "backend.success".to_string(),
999                    to: "client.in".to_string(),
1000                },
1001                EdgeConfig {
1002                    from: "error-handler.success".to_string(),
1003                    to: "client.in".to_string(),
1004                },
1005            ],
1006        };
1007
1008        let graph = compile_policy(&policy, PluginResources::empty()).unwrap();
1009        let ctx = test_context("/test");
1010        let result = graph.execute(ctx).await;
1011
1012        assert_eq!(result.response.status_code, 503);
1013        let body = String::from_utf8(result.response.body.to_vec()).unwrap();
1014        assert!(!body.contains("{{"), "template must be rendered: {body}");
1015    }
1016
1017    /// The complement of the test above: an `error-handler` reached with an
1018    /// **empty** `context.errors` — what every outcome exit looks like — must
1019    /// leave the already-prepared response alone instead of clobbering it with
1020    /// its own status and an unrendered template.
1021    #[tokio::test]
1022    async fn test_error_handler_passes_errorless_context_through() {
1023        let policy = PolicyConfig {
1024            name: "test".to_string(),
1025            error_handler: None,
1026            nodes: vec![
1027                NodeConfig {
1028                    id: "listener".to_string(),
1029                    node_type: "listener".to_string(),
1030                    config: HashMap::new(),
1031                    config_ref: None,
1032                    position: None,
1033                },
1034                NodeConfig {
1035                    id: "error-handler".to_string(),
1036                    node_type: "error-handler".to_string(),
1037                    config: {
1038                        let mut c = HashMap::new();
1039                        c.insert("status_code".to_string(), serde_json::json!(503));
1040                        c.insert(
1041                            "body_template".to_string(),
1042                            serde_json::Value::String(r#"{"error": "{{error.code}}"}"#.to_string()),
1043                        );
1044                        c
1045                    },
1046                    config_ref: None,
1047                    position: None,
1048                },
1049                NodeConfig {
1050                    id: "client".to_string(),
1051                    node_type: "client".to_string(),
1052                    config: HashMap::new(),
1053                    config_ref: None,
1054                    position: None,
1055                },
1056            ],
1057            edges: vec![
1058                EdgeConfig {
1059                    from: "listener.out".to_string(),
1060                    to: "error-handler.in".to_string(),
1061                },
1062                EdgeConfig {
1063                    from: "error-handler.success".to_string(),
1064                    to: "client.in".to_string(),
1065                },
1066            ],
1067        };
1068
1069        let graph = compile_policy(&policy, PluginResources::empty()).unwrap();
1070        let mut ctx = test_context("/test");
1071        // Stand in for an outcome exit: a fully prepared 401, no error record.
1072        ctx.response.status_code = 401;
1073        ctx.response.body = Bytes::from(r#"{"error":"unauthorized"}"#);
1074        let result = graph.execute(ctx).await;
1075
1076        assert_eq!(result.response.status_code, 401);
1077        assert_eq!(
1078            String::from_utf8(result.response.body.to_vec()).unwrap(),
1079            r#"{"error":"unauthorized"}"#
1080        );
1081    }
1082
1083    // ---- debug tracing ---------------------------------------------------
1084
1085    use crate::debug::{CaptureOptions, EdgeKind, StepOutcome, TraceRecorder, TraceSource};
1086    use std::time::Duration;
1087
1088    fn recorder(ctx: &Context) -> TraceRecorder {
1089        TraceRecorder::new(ctx, CaptureOptions::default(), 100)
1090    }
1091
1092    fn finish(rec: TraceRecorder, ctx: &Context) -> crate::debug::Trace {
1093        rec.finish(
1094            "t".to_string(),
1095            0,
1096            TraceSource::Request,
1097            Some("r".to_string()),
1098            "p".to_string(),
1099            ctx,
1100            Duration::from_millis(1),
1101        )
1102    }
1103
1104    /// A node that always fails, so error-edge routing can be exercised without
1105    /// depending on a real plugin's failure mode.
1106    struct AlwaysFails;
1107
1108    #[async_trait::async_trait]
1109    impl Plugin for AlwaysFails {
1110        fn plugin_type(&self) -> &str {
1111            "always-fails"
1112        }
1113        async fn execute(&self, ctx: Context) -> crate::plugins::PluginResult {
1114            Err(crate::plugins::PluginExecutionError {
1115                context: ctx,
1116                error: crate::context::GatewayError {
1117                    node_id: String::new(),
1118                    code: "BOOM".to_string(),
1119                    message: "exploded".to_string(),
1120                    metadata: HashMap::new(),
1121                },
1122            })
1123        }
1124    }
1125
1126    /// Builds a graph by hand so a failing node can be injected.
1127    fn failing_graph(
1128        error_edges: HashMap<String, String>,
1129        catch_all: Option<String>,
1130    ) -> CompiledGraph {
1131        let mut nodes: HashMap<String, Box<dyn Plugin>> = HashMap::new();
1132        nodes.insert("boom".to_string(), Box::new(AlwaysFails));
1133        nodes.insert(
1134            "client".to_string(),
1135            plugins::create_plugin("client", &HashMap::new(), &PluginResources::empty()).unwrap(),
1136        );
1137        let mut edges: HashMap<String, HashMap<String, String>> = HashMap::new();
1138        if let Some(target) = error_edges.get("boom") {
1139            edges
1140                .entry("boom".to_string())
1141                .or_default()
1142                .insert("error".to_string(), target.clone());
1143        }
1144        CompiledGraph {
1145            nodes,
1146            edges,
1147            entry_node_id: "boom".to_string(),
1148            terminal_node_ids: HashSet::from(["client".to_string()]),
1149            catch_all_handler: catch_all,
1150            policy_name: "p".to_string(),
1151            resources: PluginResources::empty(),
1152            stream_capable: HashSet::new(),
1153            buffering_reasons: Vec::new(),
1154            cache_pair_warnings: Vec::new(),
1155            cache_targets: Vec::new(),
1156        }
1157    }
1158
1159    fn rewrite_policy() -> PolicyConfig {
1160        let mut cfg = HashMap::new();
1161        cfg.insert(
1162            "strip_path_prefix".to_string(),
1163            serde_json::Value::String("/api/v1".to_string()),
1164        );
1165        cfg.insert(
1166            "phase".to_string(),
1167            serde_json::Value::String("request".to_string()),
1168        );
1169        PolicyConfig {
1170            name: "traced".to_string(),
1171            error_handler: None,
1172            nodes: vec![
1173                NodeConfig {
1174                    id: "listener".to_string(),
1175                    node_type: "listener".to_string(),
1176                    config: HashMap::new(),
1177                    config_ref: None,
1178                    position: None,
1179                },
1180                NodeConfig {
1181                    id: "rewrite".to_string(),
1182                    node_type: "proxy-rewrite".to_string(),
1183                    config: cfg,
1184                    config_ref: None,
1185                    position: None,
1186                },
1187                NodeConfig {
1188                    id: "client".to_string(),
1189                    node_type: "client".to_string(),
1190                    config: HashMap::new(),
1191                    config_ref: None,
1192                    position: None,
1193                },
1194            ],
1195            edges: vec![
1196                EdgeConfig {
1197                    from: "listener.out".to_string(),
1198                    to: "rewrite.in".to_string(),
1199                },
1200                EdgeConfig {
1201                    from: "rewrite.success".to_string(),
1202                    to: "client.in".to_string(),
1203                },
1204            ],
1205        }
1206    }
1207
1208    #[tokio::test]
1209    async fn test_trace_records_each_node_in_order() {
1210        let graph = compile_policy(&rewrite_policy(), PluginResources::empty()).unwrap();
1211        let ctx = test_context("/api/v1/users");
1212        let rec = recorder(&ctx);
1213        let (out, rec) = graph.execute_traced(ctx, rec).await;
1214        let trace = finish(rec, &out);
1215
1216        let ids: Vec<&str> = trace.steps.iter().map(|s| s.node_id.as_str()).collect();
1217        assert_eq!(ids, vec!["rewrite", "client"]);
1218        assert_eq!(trace.steps[0].node_type, "proxy-rewrite");
1219        assert_eq!(trace.steps[0].edge, EdgeKind::Success);
1220        assert_eq!(trace.steps[0].next_node_id.as_deref(), Some("client"));
1221        // The terminal node ends the walk.
1222        assert_eq!(trace.steps[1].edge, EdgeKind::Terminal);
1223        assert_eq!(trace.steps[1].next_node_id, None);
1224    }
1225
1226    /// The trace must show the rewrite: initial path in, rewritten path out.
1227    #[tokio::test]
1228    async fn test_trace_captures_what_the_plugin_changed() {
1229        let graph = compile_policy(&rewrite_policy(), PluginResources::empty()).unwrap();
1230        let ctx = test_context("/api/v1/users");
1231        let rec = recorder(&ctx);
1232        let (out, rec) = graph.execute_traced(ctx, rec).await;
1233        let trace = finish(rec, &out);
1234
1235        assert_eq!(trace.initial.request.path, "/api/v1/users");
1236        assert_eq!(trace.steps[0].after.request.path, "/users");
1237
1238        let changes = crate::debug::diff::diff(&trace.initial, &trace.steps[0].after);
1239        let c = changes
1240            .iter()
1241            .find(|c| c.path == "request.path")
1242            .expect("path change");
1243        assert_eq!(c.before.as_deref(), Some("/api/v1/users"));
1244        assert_eq!(c.after.as_deref(), Some("/users"));
1245    }
1246
1247    /// Tracing must never change what the gateway does. If this ever fails,
1248    /// the debug feature has become a heisenbug generator.
1249    #[tokio::test]
1250    async fn test_tracing_does_not_alter_behaviour() {
1251        let policy = rewrite_policy();
1252        let plain = compile_policy(&policy, PluginResources::empty()).unwrap();
1253        let traced = compile_policy(&policy, PluginResources::empty()).unwrap();
1254
1255        let untraced_out = plain.execute(test_context("/api/v1/users")).await;
1256        let ctx = test_context("/api/v1/users");
1257        let rec = recorder(&ctx);
1258        let (traced_out, _) = traced.execute_traced(ctx, rec).await;
1259
1260        assert_eq!(untraced_out.request.path, traced_out.request.path);
1261        assert_eq!(
1262            untraced_out.response.status_code,
1263            traced_out.response.status_code
1264        );
1265        assert_eq!(untraced_out.response.body, traced_out.response.body);
1266        assert_eq!(untraced_out.message, traced_out.message);
1267        assert_eq!(untraced_out.errors, traced_out.errors);
1268    }
1269
1270    #[tokio::test]
1271    async fn test_trace_records_error_edge() {
1272        let graph = failing_graph(
1273            HashMap::from([("boom".to_string(), "client".to_string())]),
1274            None,
1275        );
1276        let ctx = test_context("/x");
1277        let rec = recorder(&ctx);
1278        let (out, rec) = graph.execute_traced(ctx, rec).await;
1279        let trace = finish(rec, &out);
1280
1281        assert_eq!(trace.steps[0].node_id, "boom");
1282        assert_eq!(
1283            trace.steps[0].outcome,
1284            StepOutcome::Error {
1285                code: "BOOM".to_string(),
1286                message: "exploded".to_string()
1287            }
1288        );
1289        assert_eq!(trace.steps[0].edge, EdgeKind::Error);
1290        assert_eq!(trace.steps[0].next_node_id.as_deref(), Some("client"));
1291        // The error was recorded on the context the step captured.
1292        assert_eq!(trace.steps[0].after.errors.len(), 1);
1293        assert_eq!(trace.steps[0].after.errors[0].node_id, "boom");
1294    }
1295
1296    /// A node leaving through its error port is an operational event, not
1297    /// just a response body: it must be logged at WARN with the node id and
1298    /// the error code/message so an operator can see why a request failed
1299    /// without enabling debug traces.
1300    #[tokio::test]
1301    async fn test_error_port_exit_is_logged_at_warn() {
1302        let graph = failing_graph(
1303            HashMap::from([("boom".to_string(), "client".to_string())]),
1304            None,
1305        );
1306        let (_guard, logs) = crate::test_log::capture_warnings();
1307        let _ = graph.execute(test_context("/x")).await;
1308
1309        let out = logs.contents();
1310        assert!(out.contains("WARN"), "expected a WARN line, got: {out:?}");
1311        for needle in ["policy 'p'", "node 'boom'", "BOOM", "exploded"] {
1312            assert!(out.contains(needle), "missing {needle:?} in: {out:?}");
1313        }
1314    }
1315
1316    #[tokio::test]
1317    async fn test_trace_records_catch_all_edge() {
1318        let graph = failing_graph(HashMap::new(), Some("client".to_string()));
1319        let ctx = test_context("/x");
1320        let rec = recorder(&ctx);
1321        let (out, rec) = graph.execute_traced(ctx, rec).await;
1322        let trace = finish(rec, &out);
1323        assert_eq!(trace.steps[0].edge, EdgeKind::CatchAll);
1324    }
1325
1326    /// The unwired-error-port case: this is the footgun the sandbox's
1327    /// `on_error: "stop"` mode deliberately exposes.
1328    #[tokio::test]
1329    async fn test_trace_records_unhandled_error() {
1330        let graph = failing_graph(HashMap::new(), None);
1331        let ctx = test_context("/x");
1332        let rec = recorder(&ctx);
1333        let (out, rec) = graph.execute_traced(ctx, rec).await;
1334        let trace = finish(rec, &out);
1335
1336        assert_eq!(trace.steps.len(), 1);
1337        assert_eq!(trace.steps[0].edge, EdgeKind::Unhandled);
1338        assert_eq!(trace.steps[0].next_node_id, None);
1339        // ...and the engine wrote its generic 500.
1340        assert_eq!(out.response.status_code, 500);
1341        assert_eq!(trace.steps[0].after.response.status_code, 500);
1342    }
1343
1344    /// The `NODE_NOT_FOUND` path (an edge pointing at a node id absent from
1345    /// the node table — reachable because `compile_policy` validates only
1346    /// `from_node`, never `to_node`) also overwrites `response.body` with a
1347    /// generated message. Same hazard as the other two gateway-generated
1348    /// error bodies: a stale `response.stream` must not survive it.
1349    #[tokio::test]
1350    async fn test_node_not_found_clears_stale_stream() {
1351        use crate::context::stream::ResponseStream;
1352        use http_body_util::{BodyExt, Full};
1353
1354        let graph = CompiledGraph {
1355            nodes: HashMap::new(),
1356            edges: HashMap::new(),
1357            entry_node_id: "missing".to_string(),
1358            terminal_node_ids: HashSet::new(),
1359            catch_all_handler: None,
1360            policy_name: "p".to_string(),
1361            resources: PluginResources::empty(),
1362            stream_capable: HashSet::new(),
1363            buffering_reasons: Vec::new(),
1364            cache_pair_warnings: Vec::new(),
1365            cache_targets: Vec::new(),
1366        };
1367        let mut ctx = test_context("/x");
1368        let boxed = Full::new(Bytes::from_static(b"partial-stream-bytes"))
1369            .map_err(|never| match never {})
1370            .boxed();
1371        ctx.response.stream = Some(ResponseStream::new(boxed));
1372
1373        let out = graph.execute(ctx).await;
1374
1375        assert_eq!(out.response.status_code, 500);
1376        assert!(
1377            out.response.stream.is_none(),
1378            "the generated node-not-found body must not coexist with a stale stream"
1379        );
1380        assert!(String::from_utf8(out.response.body.to_vec())
1381            .unwrap()
1382            .contains("node 'missing' not found"));
1383    }
1384
1385    /// The engine's no-handler 500 fallback overwrites `response.body`. If a
1386    /// stream was set on the context that arrived here (e.g. an `upstream`
1387    /// node had started relaying a response, then a later node in the same
1388    /// chain errored with neither an error edge nor a catch-all), the stale
1389    /// stream must be cleared too — otherwise the listener would see both
1390    /// `body` and `stream` set and, per the documented invariant, send the
1391    /// half-finished stream instead of this generated 500.
1392    #[tokio::test]
1393    async fn test_unhandled_error_clears_stale_stream() {
1394        use crate::context::stream::ResponseStream;
1395        use http_body_util::{BodyExt, Full};
1396
1397        let graph = failing_graph(HashMap::new(), None);
1398        let mut ctx = test_context("/x");
1399        let boxed = Full::new(Bytes::from_static(b"partial-stream-bytes"))
1400            .map_err(|never| match never {})
1401            .boxed();
1402        ctx.response.stream = Some(ResponseStream::new(boxed));
1403
1404        let out = graph.execute(ctx).await;
1405
1406        assert_eq!(out.response.status_code, 500);
1407        assert!(
1408            out.response.stream.is_none(),
1409            "the generated 500 body must not coexist with a stale stream"
1410        );
1411        assert!(String::from_utf8(out.response.body.to_vec())
1412            .unwrap()
1413            .contains("Unhandled error in routing policy"));
1414    }
1415
1416    // ---- per-port outcome routing -----------------------------------------
1417
1418    /// A test-only plugin that emits on the "denied" outcome port when the
1419    /// request carries `x-deny`, else the normal success port. Stands in for
1420    /// a real outcome-port plugin, which doesn't exist until Task 8.
1421    struct DenyOnHeader;
1422
1423    #[async_trait::async_trait]
1424    impl Plugin for DenyOnHeader {
1425        fn plugin_type(&self) -> &str {
1426            "deny-on-header"
1427        }
1428        async fn execute(&self, ctx: Context) -> crate::plugins::PluginResult {
1429            if ctx.request.headers.contains_key("x-deny") {
1430                Ok(plugins::PluginOutput::on_port(ctx, "denied"))
1431            } else {
1432                Ok(plugins::PluginOutput::success(ctx))
1433            }
1434        }
1435    }
1436
1437    /// Builds a graph by hand with a per-port edge map, so outcome-port
1438    /// routing can be exercised without a real plugin type declaring one.
1439    fn outcome_graph() -> CompiledGraph {
1440        let mut nodes: HashMap<String, Box<dyn Plugin>> = HashMap::new();
1441        nodes.insert("n".to_string(), Box::new(DenyOnHeader));
1442        nodes.insert(
1443            "client".to_string(),
1444            plugins::create_plugin("client", &HashMap::new(), &PluginResources::empty()).unwrap(),
1445        );
1446        nodes.insert(
1447            "deny-client".to_string(),
1448            plugins::create_plugin("client", &HashMap::new(), &PluginResources::empty()).unwrap(),
1449        );
1450        let mut n_edges = HashMap::new();
1451        n_edges.insert("denied".to_string(), "deny-client".to_string());
1452        n_edges.insert("success".to_string(), "client".to_string());
1453        let mut edges = HashMap::new();
1454        edges.insert("n".to_string(), n_edges);
1455        CompiledGraph {
1456            nodes,
1457            edges,
1458            entry_node_id: "n".to_string(),
1459            terminal_node_ids: HashSet::from(["client".to_string(), "deny-client".to_string()]),
1460            catch_all_handler: None,
1461            policy_name: "p".to_string(),
1462            resources: PluginResources::empty(),
1463            stream_capable: HashSet::new(),
1464            buffering_reasons: Vec::new(),
1465            cache_pair_warnings: Vec::new(),
1466            cache_targets: Vec::new(),
1467        }
1468    }
1469
1470    /// A named outcome port routes to its wired target, not to success.
1471    #[tokio::test]
1472    async fn test_outcome_port_routes_to_its_edge() {
1473        let graph = outcome_graph();
1474
1475        let mut ctx = test_context("/x");
1476        ctx.request
1477            .headers
1478            .insert("x-deny".to_string(), vec!["1".to_string()]);
1479        let rec = recorder(&ctx);
1480        let (out, rec) = graph.execute_traced(ctx, rec).await;
1481        let trace = finish(rec, &out);
1482
1483        assert_eq!(trace.steps[0].node_id, "n");
1484        assert_eq!(trace.steps[0].edge, EdgeKind::Outcome);
1485        assert_eq!(trace.steps[0].port.as_deref(), Some("denied"));
1486        assert_eq!(trace.steps[0].next_node_id.as_deref(), Some("deny-client"));
1487        // The walk actually ended at deny-client, not client.
1488        assert_eq!(trace.steps.len(), 2);
1489        assert_eq!(trace.steps[1].node_id, "deny-client");
1490    }
1491
1492    /// Without the header, the same node exits through `success` as usual.
1493    #[tokio::test]
1494    async fn test_outcome_port_falls_back_to_success() {
1495        let graph = outcome_graph();
1496        let ctx = test_context("/x");
1497        let rec = recorder(&ctx);
1498        let (out, rec) = graph.execute_traced(ctx, rec).await;
1499        let trace = finish(rec, &out);
1500
1501        assert_eq!(trace.steps[0].edge, EdgeKind::Success);
1502        assert!(trace.steps[0].port.is_none());
1503        assert_eq!(trace.steps[0].next_node_id.as_deref(), Some("client"));
1504    }
1505
1506    /// A real `cors` node, compiled through `compile_policy` (not a hand-built
1507    /// graph), with its `preflight` port wired to `client`. An OPTIONS
1508    /// preflight from an allowed origin must exit on the `preflight` outcome
1509    /// port — not `success` — with the prepared 204 reaching the final
1510    /// context, exercising the whole compile+execute+trace path end-to-end
1511    /// for an outcome-port node.
1512    fn cors_policy() -> PolicyConfig {
1513        let mut cfg = HashMap::new();
1514        cfg.insert(
1515            "allowed_origins".to_string(),
1516            serde_json::json!(["http://sub.domain.com"]),
1517        );
1518        PolicyConfig {
1519            name: "cors-preflight".to_string(),
1520            error_handler: None,
1521            nodes: vec![
1522                NodeConfig {
1523                    id: "listener".to_string(),
1524                    node_type: "listener".to_string(),
1525                    config: HashMap::new(),
1526                    config_ref: None,
1527                    position: None,
1528                },
1529                NodeConfig {
1530                    id: "cors".to_string(),
1531                    node_type: "cors".to_string(),
1532                    config: cfg,
1533                    config_ref: None,
1534                    position: None,
1535                },
1536                NodeConfig {
1537                    id: "client".to_string(),
1538                    node_type: "client".to_string(),
1539                    config: HashMap::new(),
1540                    config_ref: None,
1541                    position: None,
1542                },
1543            ],
1544            edges: vec![
1545                EdgeConfig {
1546                    from: "listener.out".to_string(),
1547                    to: "cors.in".to_string(),
1548                },
1549                EdgeConfig {
1550                    from: "cors.success".to_string(),
1551                    to: "client.in".to_string(),
1552                },
1553                EdgeConfig {
1554                    from: "cors.preflight".to_string(),
1555                    to: "client.in".to_string(),
1556                },
1557            ],
1558        }
1559    }
1560
1561    #[tokio::test]
1562    async fn test_cors_preflight_exits_via_preflight_edge_end_to_end() {
1563        let graph = compile_policy(&cors_policy(), PluginResources::empty()).unwrap();
1564
1565        let mut ctx = test_context("/x");
1566        ctx.request.method = "OPTIONS".to_string();
1567        ctx.request.headers.insert(
1568            "origin".to_string(),
1569            vec!["http://sub.domain.com".to_string()],
1570        );
1571        let rec = recorder(&ctx);
1572        let (out, rec) = graph.execute_traced(ctx, rec).await;
1573        let trace = finish(rec, &out);
1574
1575        assert_eq!(trace.steps[0].node_id, "cors");
1576        assert_eq!(trace.steps[0].edge, EdgeKind::Outcome);
1577        assert_eq!(trace.steps[0].port.as_deref(), Some("preflight"));
1578        assert_eq!(trace.steps[0].next_node_id.as_deref(), Some("client"));
1579        assert_eq!(out.response.status_code, 204);
1580        assert_eq!(
1581            out.response.headers.get("access-control-allow-origin"),
1582            Some(&vec!["http://sub.domain.com".to_string()])
1583        );
1584    }
1585
1586    // ---- compile-time mandatory-wiring validation -------------------------
1587
1588    fn policy_missing_success_edge() -> PolicyConfig {
1589        PolicyConfig {
1590            name: "p".to_string(),
1591            error_handler: None,
1592            nodes: vec![
1593                NodeConfig {
1594                    id: "listener".to_string(),
1595                    node_type: "listener".to_string(),
1596                    config: HashMap::new(),
1597                    config_ref: None,
1598                    position: None,
1599                },
1600                NodeConfig {
1601                    id: "rw".to_string(),
1602                    node_type: "proxy-rewrite".to_string(),
1603                    config: HashMap::new(),
1604                    config_ref: None,
1605                    position: None,
1606                },
1607                NodeConfig {
1608                    id: "client".to_string(),
1609                    node_type: "client".to_string(),
1610                    config: HashMap::new(),
1611                    config_ref: None,
1612                    position: None,
1613                },
1614            ],
1615            edges: vec![EdgeConfig {
1616                from: "listener.out".to_string(),
1617                to: "rw.in".to_string(),
1618            }],
1619        }
1620    }
1621
1622    /// Unwired mandatory port fails compilation with the exact message.
1623    #[test]
1624    fn test_compile_rejects_unwired_mandatory_port() {
1625        let policy = policy_missing_success_edge();
1626        let err = compile_policy(&policy, PluginResources::empty()).unwrap_err();
1627        assert_eq!(
1628            err,
1629            "policy 'p': output port 'success' of node 'rw' (type 'proxy-rewrite') must be wired — add an edge from 'rw.success'"
1630        );
1631    }
1632
1633    /// Duplicate (node, port) edge is an explicit error, not a silent overwrite.
1634    #[test]
1635    fn test_compile_rejects_fanout() {
1636        let mut policy = policy_missing_success_edge();
1637        policy.edges.push(EdgeConfig {
1638            from: "rw.success".to_string(),
1639            to: "client.in".to_string(),
1640        });
1641        policy.edges.push(EdgeConfig {
1642            from: "rw.success".to_string(),
1643            to: "client.in".to_string(),
1644        });
1645        let err = compile_policy(&policy, PluginResources::empty()).unwrap_err();
1646        assert_eq!(
1647            err,
1648            "policy 'p': duplicate edge from 'rw.success' — fan-out is not supported"
1649        );
1650    }
1651
1652    /// Edge from a port the type does not declare.
1653    #[test]
1654    fn test_compile_rejects_undeclared_port() {
1655        let mut policy = policy_missing_success_edge();
1656        policy.edges.push(EdgeConfig {
1657            from: "rw.banana".to_string(),
1658            to: "client.in".to_string(),
1659        });
1660        let err = compile_policy(&policy, PluginResources::empty()).unwrap_err();
1661        assert_eq!(
1662            err,
1663            "policy 'p': node 'rw' (type 'proxy-rewrite') has no output port 'banana'"
1664        );
1665    }
1666
1667    /// A fully-wired graph whose edges loop back on themselves must not
1668    /// compile: the runtime walk has no step limit, so a cycle would spin
1669    /// forever on the first matching request.
1670    #[test]
1671    fn test_compile_rejects_cycle() {
1672        let mut policy = policy_missing_success_edge();
1673        policy.nodes.push(NodeConfig {
1674            id: "rw2".to_string(),
1675            node_type: "proxy-rewrite".to_string(),
1676            config: HashMap::new(),
1677            config_ref: None,
1678            position: None,
1679        });
1680        policy.edges.push(EdgeConfig {
1681            from: "rw.success".to_string(),
1682            to: "rw2.in".to_string(),
1683        });
1684        policy.edges.push(EdgeConfig {
1685            from: "rw2.success".to_string(),
1686            to: "rw.in".to_string(),
1687        });
1688        let err = compile_policy(&policy, PluginResources::empty()).unwrap_err();
1689        assert_eq!(
1690            err,
1691            "policy 'p': policy graph contains a cycle through node 'rw' — policies must be acyclic"
1692        );
1693    }
1694
1695    /// An error edge is still an edge the walk can follow, so it closes a
1696    /// cycle like any other: rw errors back into itself via rw2.
1697    #[test]
1698    fn test_compile_rejects_cycle_through_error_edge() {
1699        let mut policy = policy_missing_success_edge();
1700        policy.nodes.push(NodeConfig {
1701            id: "rw2".to_string(),
1702            node_type: "proxy-rewrite".to_string(),
1703            config: HashMap::new(),
1704            config_ref: None,
1705            position: None,
1706        });
1707        policy.edges.push(EdgeConfig {
1708            from: "rw.success".to_string(),
1709            to: "client.in".to_string(),
1710        });
1711        policy.edges.push(EdgeConfig {
1712            from: "rw.error".to_string(),
1713            to: "rw2.in".to_string(),
1714        });
1715        policy.edges.push(EdgeConfig {
1716            from: "rw2.success".to_string(),
1717            to: "rw.in".to_string(),
1718        });
1719        let err = compile_policy(&policy, PluginResources::empty()).unwrap_err();
1720        assert_eq!(
1721            err,
1722            "policy 'p': policy graph contains a cycle through node 'rw' — policies must be acyclic"
1723        );
1724    }
1725
1726    /// Fan-in stays legal: two nodes converging on the same target is not a
1727    /// cycle and must compile.
1728    #[test]
1729    fn test_compile_allows_fan_in() {
1730        let mut policy = policy_missing_success_edge();
1731        policy.nodes.push(NodeConfig {
1732            id: "rw2".to_string(),
1733            node_type: "proxy-rewrite".to_string(),
1734            config: HashMap::new(),
1735            config_ref: None,
1736            position: None,
1737        });
1738        policy.edges.push(EdgeConfig {
1739            from: "rw.success".to_string(),
1740            to: "rw2.in".to_string(),
1741        });
1742        policy.edges.push(EdgeConfig {
1743            from: "rw.error".to_string(),
1744            to: "client.in".to_string(),
1745        });
1746        policy.edges.push(EdgeConfig {
1747            from: "rw2.success".to_string(),
1748            to: "client.in".to_string(),
1749        });
1750        assert!(compile_policy(&policy, PluginResources::empty()).is_ok());
1751    }
1752
1753    /// error port stays optional: policy with no error edges still compiles.
1754    #[test]
1755    fn test_error_port_wiring_is_optional() {
1756        let mut policy = policy_missing_success_edge();
1757        policy.edges.push(EdgeConfig {
1758            from: "rw.success".to_string(),
1759            to: "client.in".to_string(),
1760        });
1761        assert!(compile_policy(&policy, PluginResources::empty()).is_ok());
1762    }
1763
1764    // ---- streaming inference -----------------------------------------
1765
1766    /// Turns a JSON policy (nodes + edges, `name` optional) into a compiled
1767    /// graph, panicking on any deserialize/compile failure so tests read as
1768    /// plain assertions.
1769    fn compile_test_policy(json: serde_json::Value) -> CompiledGraph {
1770        let mut value = json;
1771        if let serde_json::Value::Object(ref mut map) = value {
1772            map.entry("name")
1773                .or_insert_with(|| serde_json::Value::String("test".to_string()));
1774        }
1775        let policy: PolicyConfig =
1776            serde_json::from_value(value).expect("test policy JSON must deserialize");
1777        compile_policy(&policy, PluginResources::empty()).expect("test policy must compile")
1778    }
1779
1780    /// The same, for a policy that must be REJECTED: returns the compile
1781    /// error so a test can assert on what it says.
1782    fn compile_test_policy_err(json: serde_json::Value) -> String {
1783        let mut value = json;
1784        if let serde_json::Value::Object(ref mut map) = value {
1785            map.entry("name")
1786                .or_insert_with(|| serde_json::Value::String("test".to_string()));
1787        }
1788        let policy: PolicyConfig =
1789            serde_json::from_value(value).expect("test policy JSON must deserialize");
1790        compile_policy(&policy, PluginResources::empty()).expect_err("this policy must not compile")
1791    }
1792
1793    /// An upstream whose success path reaches `client` through header-only
1794    /// nodes can stream.
1795    #[test]
1796    fn test_upstream_is_stream_capable_with_header_only_tail() {
1797        let graph = compile_test_policy(serde_json::json!({
1798            "nodes": [
1799                { "id": "listener", "type": "listener", "config": {} },
1800                { "id": "up", "type": "upstream",
1801                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
1802                { "id": "hdr", "type": "response-rewrite",
1803                  "config": { "headers": { "set": { "x-a": "b" } } } },
1804                { "id": "client", "type": "client", "config": {} }
1805            ],
1806            "edges": [
1807                { "from": "listener.out", "to": "up.in" },
1808                { "from": "up.success", "to": "hdr.in" },
1809                { "from": "hdr.success", "to": "client.in" }
1810            ]
1811        }));
1812
1813        assert!(graph.is_stream_capable("up"));
1814        assert!(graph.buffering_reasons().is_empty());
1815    }
1816
1817    /// A body-rewriting node on the success path forces buffering, and the
1818    /// compiler must name it — silence here is the failure mode this feature
1819    /// exists to avoid.
1820    #[test]
1821    fn test_filters_force_buffering_and_are_reported() {
1822        let graph = compile_test_policy(serde_json::json!({
1823            "nodes": [
1824                { "id": "listener", "type": "listener", "config": {} },
1825                { "id": "up", "type": "upstream",
1826                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
1827                { "id": "rw", "type": "response-rewrite",
1828                  "config": { "filters": [{ "regex": "a", "replace": "b" }] } },
1829                { "id": "client", "type": "client", "config": {} }
1830            ],
1831            "edges": [
1832                { "from": "listener.out", "to": "up.in" },
1833                { "from": "up.success", "to": "rw.in" },
1834                { "from": "rw.success", "to": "client.in" }
1835            ]
1836        }));
1837
1838        assert!(!graph.is_stream_capable("up"));
1839        let reasons = graph.buffering_reasons();
1840        assert_eq!(reasons.len(), 1);
1841        assert_eq!(reasons[0].upstream_node_id, "up");
1842        assert_eq!(reasons[0].blocked_by_node_id, "rw");
1843    }
1844
1845    /// The error path must not influence the decision: it is taken only when
1846    /// the upstream produced no body at all.
1847    #[test]
1848    fn test_error_path_does_not_force_buffering() {
1849        let graph = compile_test_policy(serde_json::json!({
1850            "nodes": [
1851                { "id": "listener", "type": "listener", "config": {} },
1852                { "id": "up", "type": "upstream",
1853                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
1854                { "id": "errs", "type": "error-handler",
1855                  "config": { "status_code": 502, "body_template": "{}" } },
1856                { "id": "client", "type": "client", "config": {} }
1857            ],
1858            "edges": [
1859                { "from": "listener.out", "to": "up.in" },
1860                { "from": "up.success", "to": "client.in" },
1861                { "from": "up.error", "to": "errs.in" },
1862                { "from": "errs.success", "to": "client.in" }
1863            ]
1864        }));
1865
1866        assert!(
1867            graph.is_stream_capable("up"),
1868            "an error-handler on the error path must not block streaming"
1869        );
1870        assert!(graph.buffering_reasons().is_empty());
1871    }
1872
1873    /// A `script` node on the success path forces buffering. This is not
1874    /// just another case: Task 1 left comments in `lua_runtime.rs` and
1875    /// `multi_auth.rs` asserting a discarded stream is safe there *because*
1876    /// those plugins never opt out of `reads_response_body`. Without this
1877    /// test that safety argument is only prose in a ledger; this makes the
1878    /// suite enforce it.
1879    #[test]
1880    fn test_script_node_forces_buffering() {
1881        let graph = compile_test_policy(serde_json::json!({
1882            "nodes": [
1883                { "id": "listener", "type": "listener", "config": {} },
1884                { "id": "up", "type": "upstream",
1885                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
1886                { "id": "s", "type": "script",
1887                  "config": { "inline": "function execute(ctx) return ctx end" } },
1888                { "id": "client", "type": "client", "config": {} }
1889            ],
1890            "edges": [
1891                { "from": "listener.out", "to": "up.in" },
1892                { "from": "up.success", "to": "s.in" },
1893                { "from": "s.success", "to": "client.in" },
1894                { "from": "s.respond", "to": "client.in" }
1895            ]
1896        }));
1897
1898        assert!(!graph.is_stream_capable("up"));
1899        let reasons = graph.buffering_reasons();
1900        assert_eq!(reasons.len(), 1);
1901        assert_eq!(reasons[0].upstream_node_id, "up");
1902        assert_eq!(reasons[0].blocked_by_node_id, "s");
1903        assert_eq!(reasons[0].node_type, "script");
1904    }
1905
1906    /// A depth-1-only walk (checks only the upstream's direct successor)
1907    /// would already pass every test above, since all of them use a single
1908    /// hop. This one has a three-hop, all-header-only tail: only a real
1909    /// forward walk finds its way to `client` without blocking.
1910    #[test]
1911    fn test_multi_hop_header_only_chain_is_stream_capable() {
1912        let graph = compile_test_policy(serde_json::json!({
1913            "nodes": [
1914                { "id": "listener", "type": "listener", "config": {} },
1915                { "id": "up", "type": "upstream",
1916                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
1917                { "id": "hdr1", "type": "response-rewrite",
1918                  "config": { "headers": { "set": { "x-a": "1" } } } },
1919                { "id": "hdr2", "type": "response-rewrite",
1920                  "config": { "headers": { "set": { "x-b": "2" } } } },
1921                { "id": "hdr3", "type": "response-rewrite",
1922                  "config": { "headers": { "set": { "x-c": "3" } } } },
1923                { "id": "client", "type": "client", "config": {} }
1924            ],
1925            "edges": [
1926                { "from": "listener.out", "to": "up.in" },
1927                { "from": "up.success", "to": "hdr1.in" },
1928                { "from": "hdr1.success", "to": "hdr2.in" },
1929                { "from": "hdr2.success", "to": "hdr3.in" },
1930                { "from": "hdr3.success", "to": "client.in" }
1931            ]
1932        }));
1933
1934        assert!(graph.is_stream_capable("up"));
1935        assert!(graph.buffering_reasons().is_empty());
1936    }
1937
1938    /// The same three-hop chain, but the last node in the chain reads the
1939    /// body. A depth-1 implementation would report `hdr1` (or nothing at
1940    /// all) as the blocker, or miss the block entirely; a real forward walk
1941    /// must reach `hdr3` and name it.
1942    #[test]
1943    fn test_multi_hop_chain_blocks_on_body_reader_at_end() {
1944        let graph = compile_test_policy(serde_json::json!({
1945            "nodes": [
1946                { "id": "listener", "type": "listener", "config": {} },
1947                { "id": "up", "type": "upstream",
1948                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
1949                { "id": "hdr1", "type": "response-rewrite",
1950                  "config": { "headers": { "set": { "x-a": "1" } } } },
1951                { "id": "hdr2", "type": "response-rewrite",
1952                  "config": { "headers": { "set": { "x-b": "2" } } } },
1953                { "id": "hdr3", "type": "response-rewrite",
1954                  "config": { "filters": [{ "regex": "a", "replace": "b" }] } },
1955                { "id": "client", "type": "client", "config": {} }
1956            ],
1957            "edges": [
1958                { "from": "listener.out", "to": "up.in" },
1959                { "from": "up.success", "to": "hdr1.in" },
1960                { "from": "hdr1.success", "to": "hdr2.in" },
1961                { "from": "hdr2.success", "to": "hdr3.in" },
1962                { "from": "hdr3.success", "to": "client.in" }
1963            ]
1964        }));
1965
1966        assert!(!graph.is_stream_capable("up"));
1967        let reasons = graph.buffering_reasons();
1968        assert_eq!(reasons.len(), 1);
1969        assert_eq!(reasons[0].upstream_node_id, "up");
1970        assert_eq!(reasons[0].blocked_by_node_id, "hdr3");
1971        assert_eq!(reasons[0].node_type, "response-rewrite");
1972    }
1973
1974    /// A `proxy-cache` purge node on the success path must force buffering,
1975    /// even though `Role::Purge` reads nothing from the response it passes
1976    /// through: it *can* return `Err` from `execute` on a failed backend, and
1977    /// `infer_stream_capability`'s doc comment explains why that disqualifies
1978    /// it from opting out — an error edge from it could route to a node that
1979    /// writes `response.body` directly while a stream from the upstream is
1980    /// still live. Both `success` and `hit` are mandatory wiring on every
1981    /// `proxy-cache` node regardless of phase (the same `PortSpec` as
1982    /// lookup/store), even though a purge never emits `hit`, so both are
1983    /// wired to `client.in` here.
1984    #[test]
1985    fn test_proxy_cache_purge_node_forces_buffering_because_it_can_fail() {
1986        let graph = compile_test_policy(serde_json::json!({
1987            "nodes": [
1988                { "id": "listener", "type": "listener", "config": {} },
1989                { "id": "up", "type": "upstream",
1990                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
1991                { "id": "purge", "type": "proxy-cache",
1992                  "config": { "phase": "purge", "id": "products", "policy": "local" } },
1993                { "id": "client", "type": "client", "config": {} }
1994            ],
1995            "edges": [
1996                { "from": "listener.out", "to": "up.in" },
1997                { "from": "up.success", "to": "purge.in" },
1998                { "from": "purge.success", "to": "client.in" },
1999                { "from": "purge.hit", "to": "client.in" }
2000            ]
2001        }));
2002
2003        assert!(!graph.is_stream_capable("up"));
2004        let reasons = graph.buffering_reasons();
2005        assert_eq!(reasons.len(), 1);
2006        assert_eq!(reasons[0].upstream_node_id, "up");
2007        assert_eq!(reasons[0].blocked_by_node_id, "purge");
2008        assert_eq!(reasons[0].node_type, "proxy-cache");
2009    }
2010
2011    /// A `proxy-cache` `lookup` node is normally placed *before* `upstream`,
2012    /// but nothing stops it from being wired after one too (unusual, but
2013    /// legal). Placed there, it must NOT force buffering: this pins the
2014    /// `Lookup`-only opt-out from `test_proxy_cache_purge_node_forces_
2015    /// buffering_because_it_can_fail`'s sibling case, proving it is the role
2016    /// -- not just the node type -- that decides.
2017    #[test]
2018    fn test_proxy_cache_lookup_node_after_upstream_does_not_force_buffering() {
2019        let graph = compile_test_policy(serde_json::json!({
2020            "nodes": [
2021                { "id": "listener", "type": "listener", "config": {} },
2022                { "id": "up", "type": "upstream",
2023                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
2024                { "id": "look", "type": "proxy-cache",
2025                  "config": { "phase": "lookup", "id": "products", "policy": "local" } },
2026                { "id": "client", "type": "client", "config": {} }
2027            ],
2028            "edges": [
2029                { "from": "listener.out", "to": "up.in" },
2030                { "from": "up.success", "to": "look.in" },
2031                { "from": "look.success", "to": "client.in" },
2032                { "from": "look.hit", "to": "client.in" }
2033            ]
2034        }));
2035
2036        assert!(graph.is_stream_capable("up"));
2037        assert!(graph.buffering_reasons().is_empty());
2038    }
2039
2040    /// Two independent policies worth of `upstream` in one graph (a
2041    /// `condition` node routes to one or the other), one capable and one
2042    /// blocked — proving each upstream is judged on its own success path,
2043    /// not on the graph as a whole.
2044    #[test]
2045    fn test_two_upstreams_judged_independently() {
2046        let graph = compile_test_policy(serde_json::json!({
2047            "nodes": [
2048                { "id": "listener", "type": "listener", "config": {} },
2049                { "id": "cond", "type": "condition",
2050                  "config": { "conditions": [["uri", "==", "/x"]] } },
2051                { "id": "up1", "type": "upstream",
2052                  "config": { "targets": [{ "host": "h1", "port": 80 }] } },
2053                { "id": "up2", "type": "upstream",
2054                  "config": { "targets": [{ "host": "h2", "port": 80 }] } },
2055                { "id": "s", "type": "script",
2056                  "config": { "inline": "function execute(ctx) return ctx end" } },
2057                { "id": "client", "type": "client", "config": {} }
2058            ],
2059            "edges": [
2060                { "from": "listener.out", "to": "cond.in" },
2061                { "from": "cond.true", "to": "up1.in" },
2062                { "from": "cond.false", "to": "up2.in" },
2063                { "from": "up1.success", "to": "client.in" },
2064                { "from": "up2.success", "to": "s.in" },
2065                { "from": "s.success", "to": "client.in" },
2066                { "from": "s.respond", "to": "client.in" }
2067            ]
2068        }));
2069
2070        assert!(graph.is_stream_capable("up1"));
2071        assert!(!graph.is_stream_capable("up2"));
2072        let reasons = graph.buffering_reasons();
2073        assert_eq!(reasons.len(), 1);
2074        assert_eq!(reasons[0].upstream_node_id, "up2");
2075        assert_eq!(reasons[0].blocked_by_node_id, "s");
2076    }
2077
2078    /// A no-op test plugin whose `reads_response_body` is set at
2079    /// construction, standing in for a real plugin so the diamond test below
2080    /// can shape a branching graph (two outcome ports on one node) that no
2081    /// currently-registered plugin type has.
2082    struct StubPlugin {
2083        reads_body: bool,
2084    }
2085
2086    #[async_trait::async_trait]
2087    impl Plugin for StubPlugin {
2088        fn plugin_type(&self) -> &str {
2089            "stub"
2090        }
2091        fn reads_response_body(&self) -> bool {
2092            self.reads_body
2093        }
2094        async fn execute(&self, ctx: Context) -> crate::plugins::PluginResult {
2095            Ok(plugins::PluginOutput::success(ctx))
2096        }
2097    }
2098
2099    /// `up.success -> a`, `a` branches to `b` and `c`, both rejoin at
2100    /// `client`; only `c` reads the response body. Exercises the part a
2101    /// straight-line (single-path) walk can't: multiple live branches off
2102    /// one node, only one of which blocks.
2103    ///
2104    /// Built by hand and driven through `infer_stream_capability` directly
2105    /// (rather than `compile_test_policy`) because no registered plugin type
2106    /// declares two non-error outcome ports without also defaulting to
2107    /// `reads_response_body() == true` itself, which would block the walk at
2108    /// `a` and never exercise the branch/rejoin logic this test is for.
2109    #[test]
2110    fn test_diamond_blocks_when_one_branch_reads_body() {
2111        let mut nodes: HashMap<String, Box<dyn Plugin>> = HashMap::new();
2112        nodes.insert("a".to_string(), Box::new(StubPlugin { reads_body: false }));
2113        nodes.insert("b".to_string(), Box::new(StubPlugin { reads_body: false }));
2114        nodes.insert("c".to_string(), Box::new(StubPlugin { reads_body: true }));
2115        nodes.insert(
2116            "client".to_string(),
2117            plugins::create_plugin("client", &HashMap::new(), &PluginResources::empty()).unwrap(),
2118        );
2119
2120        let mut edges: HashMap<String, HashMap<String, String>> = HashMap::new();
2121        edges.insert(
2122            "up".to_string(),
2123            HashMap::from([("success".to_string(), "a".to_string())]),
2124        );
2125        edges.insert(
2126            "a".to_string(),
2127            HashMap::from([
2128                ("true".to_string(), "b".to_string()),
2129                ("false".to_string(), "c".to_string()),
2130            ]),
2131        );
2132        edges.insert(
2133            "b".to_string(),
2134            HashMap::from([("success".to_string(), "client".to_string())]),
2135        );
2136        edges.insert(
2137            "c".to_string(),
2138            HashMap::from([("success".to_string(), "client".to_string())]),
2139        );
2140
2141        let policy_nodes = vec![NodeConfig {
2142            id: "up".to_string(),
2143            node_type: "upstream".to_string(),
2144            config: HashMap::new(),
2145            config_ref: None,
2146            position: None,
2147        }];
2148
2149        let (stream_capable, reasons) = infer_stream_capability(&policy_nodes, &nodes, &edges);
2150
2151        assert!(!stream_capable.contains("up"));
2152        assert_eq!(reasons.len(), 1);
2153        assert_eq!(reasons[0].upstream_node_id, "up");
2154        assert_eq!(reasons[0].blocked_by_node_id, "c");
2155        assert_eq!(reasons[0].node_type, "stub");
2156    }
2157
2158    // ---- __may_stream engine signal -----------------------------------
2159
2160    /// CRITICAL regression: `__may_stream` must not leak across an
2161    /// error-port hop. Shape: `up1` (stream-capable: its success path goes
2162    /// straight to `client`) fails and routes via its `error` port to `up2`
2163    /// (NOT capable: its success path is blocked by `rw`, a
2164    /// `response-rewrite` filter that reads the body) — an ordinary
2165    /// primary/failover-with-a-response-transform policy. `ctx`/`ctx.message`
2166    /// survives the error-port hop, so if the engine only ever *inserted*
2167    /// `true` for a capable node and never overwrote it for a non-capable
2168    /// one, `up2` would inherit the stale `true` `up1` left behind, stream
2169    /// instead of buffering, and silently bypass `rw`.
2170    #[tokio::test]
2171    async fn test_failover_to_non_capable_upstream_does_not_leak_may_stream() {
2172        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2173
2174        // up2's real backend: replies with a body the `rw` filter can
2175        // visibly transform, so a pass proves `rw` actually ran on a
2176        // buffered body — not just that `response.stream` happened to be
2177        // unset for some unrelated reason.
2178        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2179        let port = listener.local_addr().unwrap().port();
2180        tokio::spawn(async move {
2181            if let Ok((mut stream, _)) = listener.accept().await {
2182                let mut buf = [0u8; 4096];
2183                let _ = stream.read(&mut buf).await;
2184                let _ = stream
2185                    .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 3\r\n\r\naaa")
2186                    .await;
2187                let _ = stream.shutdown().await;
2188            }
2189        });
2190
2191        let policy = PolicyConfig {
2192            name: "test".to_string(),
2193            error_handler: None,
2194            nodes: vec![
2195                NodeConfig {
2196                    id: "listener".to_string(),
2197                    node_type: "listener".to_string(),
2198                    config: HashMap::new(),
2199                    config_ref: None,
2200                    position: None,
2201                },
2202                NodeConfig {
2203                    id: "up1".to_string(),
2204                    node_type: "upstream".to_string(),
2205                    config: {
2206                        let mut c = HashMap::new();
2207                        // Nothing listens on port 1: connect fails instantly.
2208                        c.insert(
2209                            "targets".to_string(),
2210                            serde_json::json!([{ "host": "127.0.0.1", "port": 1 }]),
2211                        );
2212                        c.insert("timeout_ms".to_string(), serde_json::json!(500));
2213                        c
2214                    },
2215                    config_ref: None,
2216                    position: None,
2217                },
2218                NodeConfig {
2219                    id: "up2".to_string(),
2220                    node_type: "upstream".to_string(),
2221                    config: {
2222                        let mut c = HashMap::new();
2223                        c.insert(
2224                            "targets".to_string(),
2225                            serde_json::json!([{ "host": "127.0.0.1", "port": port }]),
2226                        );
2227                        c
2228                    },
2229                    config_ref: None,
2230                    position: None,
2231                },
2232                NodeConfig {
2233                    id: "rw".to_string(),
2234                    node_type: "response-rewrite".to_string(),
2235                    config: {
2236                        let mut c = HashMap::new();
2237                        c.insert(
2238                            "filters".to_string(),
2239                            serde_json::json!([{ "regex": "a", "replace": "b" }]),
2240                        );
2241                        c
2242                    },
2243                    config_ref: None,
2244                    position: None,
2245                },
2246                NodeConfig {
2247                    id: "client".to_string(),
2248                    node_type: "client".to_string(),
2249                    config: HashMap::new(),
2250                    config_ref: None,
2251                    position: None,
2252                },
2253            ],
2254            edges: vec![
2255                EdgeConfig {
2256                    from: "listener.out".to_string(),
2257                    to: "up1.in".to_string(),
2258                },
2259                EdgeConfig {
2260                    from: "up1.success".to_string(),
2261                    to: "client.in".to_string(),
2262                },
2263                EdgeConfig {
2264                    from: "up1.error".to_string(),
2265                    to: "up2.in".to_string(),
2266                },
2267                EdgeConfig {
2268                    from: "up2.success".to_string(),
2269                    to: "rw.in".to_string(),
2270                },
2271                EdgeConfig {
2272                    from: "rw.success".to_string(),
2273                    to: "client.in".to_string(),
2274                },
2275            ],
2276        };
2277
2278        let graph = compile_policy(&policy, PluginResources::empty()).unwrap();
2279        // Sanity-check the shape actually exercises the bug: up1 capable,
2280        // up2 not (confirms the compile-time inference agrees before we
2281        // even get to the runtime assertion below).
2282        assert!(
2283            graph.is_stream_capable("up1"),
2284            "up1's direct success path to client is header-only"
2285        );
2286        assert!(
2287            !graph.is_stream_capable("up2"),
2288            "up2's success path is blocked by rw's body filter"
2289        );
2290
2291        let result = graph.execute(test_context("/test")).await;
2292
2293        assert!(
2294            result.response.stream.is_none(),
2295            "up2 must have buffered: a stale __may_stream=true left by up1's \
2296             failed attempt must not leak across the error-port hop"
2297        );
2298        assert_eq!(
2299            result.response.body.as_ref(),
2300            b"baa",
2301            "rw's filter must have actually run on a real buffered body, \
2302             proving up2 did not silently bypass it by streaming"
2303        );
2304    }
2305
2306    /// The payoff for the logger opt-out: `upstream -> http-logger -> client`
2307    /// is an ordinary policy, and every logger used to force it to buffer even
2308    /// when its format never mentioned the body.
2309    // `http-logger` starts a batch processor when it is constructed, so
2310    // building this policy needs a reactor even though the inference itself
2311    // is synchronous.
2312    #[tokio::test]
2313    async fn test_logger_with_a_body_free_format_is_stream_capable() {
2314        let graph = compile_test_policy(serde_json::json!({
2315            "nodes": [
2316                { "id": "listener", "type": "listener", "config": {} },
2317                { "id": "up", "type": "upstream",
2318                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
2319                { "id": "log", "type": "http-logger",
2320                  "config": { "uri": "http://localhost:9/log",
2321                              "log_format": { "path": "{{request.path}}",
2322                                              "status": "{{response.status}}" } } },
2323                { "id": "client", "type": "client", "config": {} }
2324            ],
2325            "edges": [
2326                { "from": "listener.out", "to": "up.in" },
2327                { "from": "up.success", "to": "log.in" },
2328                { "from": "log.success", "to": "client.in" }
2329            ]
2330        }));
2331
2332        assert!(graph.is_stream_capable("up"));
2333        assert!(graph.buffering_reasons().is_empty());
2334    }
2335
2336    /// A logger whose format does read the body still blocks, and the compiler
2337    /// must name it rather than silently logging an empty body.
2338    // `http-logger` starts a batch processor when it is constructed, so
2339    // building this policy needs a reactor even though the inference itself
2340    // is synchronous.
2341    #[tokio::test]
2342    async fn test_logger_reading_the_body_still_forces_buffering_and_is_reported() {
2343        let graph = compile_test_policy(serde_json::json!({
2344            "nodes": [
2345                { "id": "listener", "type": "listener", "config": {} },
2346                { "id": "up", "type": "upstream",
2347                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
2348                { "id": "log", "type": "http-logger",
2349                  "config": { "uri": "http://localhost:9/log",
2350                              "log_format": { "body": "{{response.body}}" } } },
2351                { "id": "client", "type": "client", "config": {} }
2352            ],
2353            "edges": [
2354                { "from": "listener.out", "to": "up.in" },
2355                { "from": "up.success", "to": "log.in" },
2356                { "from": "log.success", "to": "client.in" }
2357            ]
2358        }));
2359
2360        assert!(!graph.is_stream_capable("up"));
2361        let reasons = graph.buffering_reasons();
2362        assert_eq!(reasons.len(), 1);
2363        assert_eq!(reasons[0].blocked_by_node_id, "log");
2364    }
2365
2366    /// A logger with no `log_format` keeps buffering: the default entry
2367    /// records the body length, which streaming would reduce to 0.
2368    // `http-logger` starts a batch processor when it is constructed, so
2369    // building this policy needs a reactor even though the inference itself
2370    // is synchronous.
2371    #[tokio::test]
2372    async fn test_logger_without_a_format_still_forces_buffering() {
2373        let graph = compile_test_policy(serde_json::json!({
2374            "nodes": [
2375                { "id": "listener", "type": "listener", "config": {} },
2376                { "id": "up", "type": "upstream",
2377                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
2378                { "id": "log", "type": "http-logger",
2379                  "config": { "uri": "http://localhost:9/log" } },
2380                { "id": "client", "type": "client", "config": {} }
2381            ],
2382            "edges": [
2383                { "from": "listener.out", "to": "up.in" },
2384                { "from": "up.success", "to": "log.in" },
2385                { "from": "log.success", "to": "client.in" }
2386            ]
2387        }));
2388
2389        assert!(!graph.is_stream_capable("up"));
2390    }
2391
2392    /// A cache pair links its two halves by `id`. If they disagree about the
2393    /// backend, the store half writes where the lookup half never reads: the
2394    /// route compiles, serves traffic, and returns a permanent 100% miss with
2395    /// no error anywhere. There is no configuration for which that is correct,
2396    /// so it is rejected rather than reported.
2397    #[tokio::test]
2398    async fn test_a_cache_pair_split_across_backends_is_rejected() {
2399        let err = compile_test_policy_err(serde_json::json!({
2400            "nodes": [
2401                { "id": "listener", "type": "listener", "config": {} },
2402                { "id": "look", "type": "proxy-cache",
2403                  "config": { "phase": "lookup", "id": "products", "policy": "local" } },
2404                { "id": "up", "type": "upstream",
2405                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
2406                { "id": "keep", "type": "proxy-cache",
2407                  "config": { "phase": "store", "id": "products", "policy": "redis", "store": "s" } },
2408                { "id": "client", "type": "client", "config": {} }
2409            ],
2410            "edges": [
2411                { "from": "listener.out", "to": "look.in" },
2412                { "from": "look.success", "to": "up.in" },
2413                { "from": "look.hit", "to": "client.in" },
2414                { "from": "up.success", "to": "keep.in" },
2415                { "from": "keep.success", "to": "client.in" }
2416            ]
2417        }));
2418
2419        assert!(
2420            err.contains("products"),
2421            "the error must name the pair: {err}"
2422        );
2423        assert!(
2424            err.contains("look") && err.contains("keep"),
2425            "and both halves: {err}"
2426        );
2427    }
2428
2429    /// The same failure arrives through a different key: same backend kind,
2430    /// different store, so the two halves address different redis instances.
2431    #[tokio::test]
2432    async fn test_a_cache_pair_split_across_stores_is_rejected() {
2433        let err = compile_test_policy_err(serde_json::json!({
2434            "nodes": [
2435                { "id": "listener", "type": "listener", "config": {} },
2436                { "id": "look", "type": "proxy-cache",
2437                  "config": { "phase": "lookup", "id": "products", "policy": "redis", "store": "a" } },
2438                { "id": "up", "type": "upstream",
2439                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
2440                { "id": "keep", "type": "proxy-cache",
2441                  "config": { "phase": "store", "id": "products", "policy": "redis", "store": "b" } },
2442                { "id": "client", "type": "client", "config": {} }
2443            ],
2444            "edges": [
2445                { "from": "listener.out", "to": "look.in" },
2446                { "from": "look.success", "to": "up.in" },
2447                { "from": "look.hit", "to": "client.in" },
2448                { "from": "up.success", "to": "keep.in" },
2449                { "from": "keep.success", "to": "client.in" }
2450            ]
2451        }));
2452
2453        assert!(
2454            err.contains("store"),
2455            "the error must say which key disagrees: {err}"
2456        );
2457    }
2458
2459    /// Two independent pairs must not be compared with each other -- only
2460    /// halves sharing an `id` form a pair, and a false positive here would
2461    /// reject a perfectly ordinary two-cache policy.
2462    #[tokio::test]
2463    async fn test_two_independent_cache_pairs_do_not_collide() {
2464        let graph = compile_test_policy(serde_json::json!({
2465            "nodes": [
2466                { "id": "listener", "type": "listener", "config": {} },
2467                { "id": "look-a", "type": "proxy-cache",
2468                  "config": { "phase": "lookup", "id": "alpha", "policy": "local" } },
2469                { "id": "look-b", "type": "proxy-cache",
2470                  "config": { "phase": "lookup", "id": "beta", "policy": "local" } },
2471                { "id": "up", "type": "upstream",
2472                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
2473                { "id": "keep-a", "type": "proxy-cache",
2474                  "config": { "phase": "store", "id": "alpha", "policy": "local" } },
2475                { "id": "keep-b", "type": "proxy-cache",
2476                  "config": { "phase": "store", "id": "beta", "policy": "local" } },
2477                { "id": "client", "type": "client", "config": {} }
2478            ],
2479            "edges": [
2480                { "from": "listener.out", "to": "look-a.in" },
2481                { "from": "look-a.success", "to": "look-b.in" },
2482                { "from": "look-a.hit", "to": "client.in" },
2483                { "from": "look-b.success", "to": "up.in" },
2484                { "from": "look-b.hit", "to": "client.in" },
2485                { "from": "up.success", "to": "keep-a.in" },
2486                { "from": "keep-a.success", "to": "keep-b.in" },
2487                { "from": "keep-b.success", "to": "client.in" },
2488                // The port spec is per TYPE, not per role, so a store-role
2489                // node declares `hit` too and the compiler requires it wired.
2490                { "from": "keep-a.hit", "to": "client.in" },
2491                { "from": "keep-b.hit", "to": "client.in" }
2492            ]
2493        }));
2494
2495        assert!(
2496            graph.cache_pair_warnings().is_empty(),
2497            "both pairs are complete and agree"
2498        );
2499    }
2500
2501    /// A lookup with no store caches nothing; a store with no lookup is never
2502    /// read. Both are silent no-ops -- but they are also what a policy looks
2503    /// like halfway through being built, so they are reported rather than
2504    /// rejected, the same way a buffering reason is.
2505    #[tokio::test]
2506    async fn test_a_lone_cache_half_is_reported_not_rejected() {
2507        let graph = compile_test_policy(serde_json::json!({
2508            "nodes": [
2509                { "id": "listener", "type": "listener", "config": {} },
2510                { "id": "look", "type": "proxy-cache",
2511                  "config": { "phase": "lookup", "id": "orphan", "policy": "local" } },
2512                { "id": "up", "type": "upstream",
2513                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
2514                { "id": "client", "type": "client", "config": {} }
2515            ],
2516            "edges": [
2517                { "from": "listener.out", "to": "look.in" },
2518                { "from": "look.success", "to": "up.in" },
2519                { "from": "look.hit", "to": "client.in" },
2520                { "from": "up.success", "to": "client.in" }
2521            ]
2522        }));
2523
2524        let warnings = graph.cache_pair_warnings();
2525        assert_eq!(warnings.len(), 1, "one incomplete pair: {warnings:?}");
2526        assert_eq!(warnings[0].cache_id, "orphan");
2527        assert_eq!(warnings[0].present_node_id, "look");
2528        assert_eq!(warnings[0].missing_role, "store");
2529    }
2530
2531    /// The breaking change, kept visible: PortSpec is per node type, so a
2532    /// script node with no `respond` edge no longer compiles. The message
2533    /// names the port so the fix is obvious.
2534    #[tokio::test]
2535    async fn test_a_script_node_must_wire_respond() {
2536        let err = compile_test_policy_err(serde_json::json!({
2537            "nodes": [
2538                { "id": "listener", "type": "listener", "config": {} },
2539                { "id": "s", "type": "script",
2540                  "config": { "runtime": "lua", "inline": "function execute(ctx) return ctx end" } },
2541                { "id": "client", "type": "client", "config": {} }
2542            ],
2543            "edges": [
2544                { "from": "listener.out", "to": "s.in" },
2545                { "from": "s.success", "to": "client.in" }
2546            ]
2547        }));
2548        assert!(err.contains("respond") && err.contains("'s'"), "{err}");
2549    }
2550
2551    /// End to end through the graph: the script's own 403 reaches the
2552    /// client because the node left on `respond`, skipping the upstream
2553    /// that would otherwise have replaced it. A browser UA takes success and
2554    /// gets the upstream's body -- the control that proves the branch.
2555    #[tokio::test]
2556    async fn test_a_script_taking_respond_short_circuits_the_upstream() {
2557        let graph = compile_test_policy(serde_json::json!({
2558            "nodes": [
2559                { "id": "listener", "type": "listener", "config": {} },
2560                { "id": "block", "type": "script", "config": { "runtime": "lua", "inline":
2561                    "function execute(ctx)\n  local ua = (ctx.request.headers[\"user-agent\"] or {})[1] or \"\"\n  if string.find(string.lower(ua), \"scrapy\") then\n    ctx.response.status_code = 403\n    ctx.response.body = \"blocked\"\n    return ctx, \"respond\"\n  end\n  return ctx\nend" } },
2562                { "id": "up", "type": "mocking", "config": { "response_status": 200, "response_example": "proxied" } },
2563                { "id": "client", "type": "client", "config": {} }
2564            ],
2565            "edges": [
2566                { "from": "listener.out", "to": "block.in" },
2567                { "from": "block.respond", "to": "client.in" },
2568                { "from": "block.success", "to": "up.in" },
2569                { "from": "up.success", "to": "client.in" }
2570            ]
2571        }));
2572
2573        let mut bot = test_context("/x");
2574        bot.request
2575            .headers
2576            .insert("user-agent".to_string(), vec!["scrapy/2.0".to_string()]);
2577        let out = graph.execute(bot).await;
2578        assert_eq!(out.response.status_code, 403);
2579        assert_eq!(out.response.body, bytes::Bytes::from_static(b"blocked"));
2580
2581        let mut browser = test_context("/x");
2582        browser
2583            .request
2584            .headers
2585            .insert("user-agent".to_string(), vec!["Mozilla/5.0".to_string()]);
2586        let out = graph.execute(browser).await;
2587        assert_eq!(out.response.status_code, 200);
2588        assert_eq!(out.response.body, bytes::Bytes::from_static(b"proxied"));
2589    }
2590
2591    /// A purge half is held to the same agreement rule as the other two: a
2592    /// purge pointed at a different backend than its pair clears nothing.
2593    #[tokio::test]
2594    async fn test_a_purge_half_split_from_its_pair_is_rejected() {
2595        let err = compile_test_policy_err(serde_json::json!({
2596            "nodes": [
2597                { "id": "listener", "type": "listener", "config": {} },
2598                { "id": "look", "type": "proxy-cache",
2599                  "config": { "phase": "lookup", "id": "products", "policy": "local" } },
2600                { "id": "up", "type": "upstream",
2601                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
2602                { "id": "drop", "type": "proxy-cache",
2603                  "config": { "phase": "purge", "id": "products", "policy": "redis", "store": "s" } },
2604                { "id": "client", "type": "client", "config": {} }
2605            ],
2606            "edges": [
2607                { "from": "listener.out", "to": "look.in" },
2608                { "from": "look.success", "to": "up.in" },
2609                { "from": "look.hit", "to": "client.in" },
2610                { "from": "up.success", "to": "drop.in" },
2611                { "from": "drop.success", "to": "client.in" },
2612                { "from": "drop.hit", "to": "client.in" }
2613            ]
2614        }));
2615        assert!(err.contains("products") && err.contains("drop"), "{err}");
2616    }
2617
2618    /// A purge with nothing to purge for is useless but not wrong -- reported
2619    /// like any other lone half.
2620    #[tokio::test]
2621    async fn test_a_lone_purge_half_is_reported() {
2622        let graph = compile_test_policy(serde_json::json!({
2623            "nodes": [
2624                { "id": "listener", "type": "listener", "config": {} },
2625                { "id": "up", "type": "upstream",
2626                  "config": { "targets": [{ "host": "h", "port": 80 }] } },
2627                { "id": "drop", "type": "proxy-cache",
2628                  "config": { "phase": "purge", "id": "orphan", "policy": "local" } },
2629                { "id": "client", "type": "client", "config": {} }
2630            ],
2631            "edges": [
2632                { "from": "listener.out", "to": "up.in" },
2633                { "from": "up.success", "to": "drop.in" },
2634                { "from": "drop.success", "to": "client.in" },
2635                { "from": "drop.hit", "to": "client.in" }
2636            ]
2637        }));
2638        let w = graph.cache_pair_warnings();
2639        assert_eq!(w.len(), 1);
2640        assert_eq!(w[0].present_node_id, "drop");
2641    }
2642}