Skip to main content

featherbit/graph/
expand.rs

1//! Compile-time expansion of supernode instances into flat policies.
2//!
3//! A policy node of `type: supernode` (config `{ name: <supernode> }`) is
4//! replaced by the referenced definition's inner nodes, ids namespaced
5//! `<instance-id>/<inner-id>`. Boundary pseudo-nodes (`input`/`output`/
6//! `error`) are spliced onto the instance's outer edges. Runs after
7//! [`validate_policy`](crate::graph::validate_policy) and before
8//! [`compile_policy`](crate::graph::compile_policy); the engine never sees
9//! a `supernode` node type.
10//!
11//! A definition may declare one or more `output` boundary nodes and one or
12//! more `error` boundary nodes; each boundary node's id names an instance
13//! port. For `output` boundaries this goes through
14//! [`port_for_output_boundary`] — the special id `output` keeps the
15//! historical `success` mapping, any other id IS the port name. For `error`
16//! boundaries the id IS the port name directly, with no such special-case
17//! mapping — except that the id `error` is also the one the black-box rule
18//! targets by default. So an instance's exit map is per-boundary: each
19//! output/error boundary has its own `exit_to` target (the `to` of the
20//! matching outer `inst.<port>` edge). Every output-derived port is
21//! **mandatory-wired** — an unwired one is a hard compile error naming the
22//! instance and port, because the instance node is gone before
23//! post-expansion port validation runs and nothing downstream could catch
24//! it otherwise. Error-kind boundaries stay optional: an unwired one just
25//! drops the corresponding edges (the policy catch-all, or the generic 500,
26//! takes over). The black-box guarantee — every inner node with no error
27//! edge of its own gets an implicit error edge — follows ONLY the
28//! `error`-id boundary; other named error boundaries carry no such default.
29
30use std::collections::{HashMap, HashSet};
31
32use crate::config::{EdgeConfig, NodeConfig, PolicyConfig, SupernodeConfig};
33
34/// Reserved boundary pseudo-node types (and required ids) in a definition.
35/// Consumed by [`expand_policy`] and downstream tasks.
36pub(crate) const BOUNDARY_TYPES: [&str; 3] = ["input", "output", "error"];
37
38/// Splits `node_id.port` on the **last** dot, defaulting the port to `out`.
39/// Mirror of `engine::parse_edge_endpoint` — the two must agree.
40/// Consumed by [`expand_policy`] and downstream tasks.
41pub(crate) fn split_endpoint(s: &str) -> (&str, &str) {
42    match s.rfind('.') {
43        Some(i) => (&s[..i], &s[i + 1..]),
44        None => (s, "out"),
45    }
46}
47
48/// Instance port name exposed by an output boundary node: the special id
49/// `output` keeps the historical `success` mapping; any other id is the
50/// port name itself. Consumed by expansion and its tests; the UI mirrors
51/// this mapping in ui/src/policyGraph.ts::supernodePortSpec.
52pub(crate) fn port_for_output_boundary(boundary_id: &str) -> &str {
53    if boundary_id == "output" {
54        "success"
55    } else {
56        boundary_id
57    }
58}
59
60/// Inlines every `type: supernode` node of `policy` using `supernodes`.
61///
62/// Splicing rules (spec §2):
63/// - outer `X.p -> inst.in` is redirected to the target of the definition's
64///   `input.out` edge (prefixed);
65/// - an instance exposes one output port per `output` boundary node — named
66///   via [`port_for_output_boundary`] — plus one port per `error` boundary
67///   node, named directly by its id. `out` is accepted as an alias for the
68///   `output`-id boundary's `success` port. Any other port name on an outer
69///   edge leaving the instance is rejected, listing the exposed ports (output
70///   ports, then error ports); so is a second edge from the same port;
71/// - every output-derived port is mandatory-wired: an outer edge from
72///   `inst.<port>` must exist for each `output` boundary, or expansion fails
73///   naming the instance and port — the instance node is gone before
74///   post-expansion port validation runs, so this is the only place that can
75///   catch it;
76/// - inner edges into an `output` boundary are redirected to the target of
77///   that boundary's outer edge (always present, per the rule above);
78/// - inner edges into an `error` boundary follow that boundary's outer
79///   `inst.<port>` edge, or are dropped when it is unwired (the policy
80///   catch-all, or the generic 500, takes over — error-kind ports are
81///   genuinely optional);
82/// - every inner node with no error edge of its own gets an implicit error
83///   edge to the outer target of the DEFAULT `error`-id boundary, when one is
84///   wired (black-box guarantee) — other named error boundaries carry no
85///   such default.
86///
87/// Consumed by [`compile_policy`] at graph-compilation time.
88pub fn expand_policy(
89    policy: &PolicyConfig,
90    supernodes: &[SupernodeConfig],
91) -> Result<PolicyConfig, String> {
92    let instances: Vec<&NodeConfig> = policy
93        .nodes
94        .iter()
95        .filter(|n| n.node_type == "supernode")
96        .collect();
97    if instances.is_empty() {
98        return Ok(policy.clone());
99    }
100
101    let by_name: HashMap<&str, &SupernodeConfig> =
102        supernodes.iter().map(|s| (s.name.as_str(), s)).collect();
103
104    /// Boundary wiring resolved per instance.
105    struct Splice<'a> {
106        def: &'a SupernodeConfig,
107        /// Boundary id -> type map for the definition (e.g., "input" -> "input").
108        boundary_map: HashMap<String, String>,
109        /// The node id of the input boundary (e.g., "input" or "in1" if {id: in1, type: input}).
110        input_node_id: String,
111        /// Prefixed entry endpoint, e.g. `sec/auth.in`, or None for pass-through boundaries.
112        entry: Option<String>,
113        /// For pass-through instances: the boundary node id the entry edge targets.
114        pass_through_boundary: Option<String>,
115        /// Boundary node id -> `to` endpoint of the outer edge wired to its port.
116        /// Mandatory-wiring guarantees an entry for every output boundary;
117        /// error-kind boundaries' entries are optional.
118        exit_to: HashMap<String, String>,
119    }
120
121    let mut splices: HashMap<&str, Splice> = HashMap::new();
122    for inst in &instances {
123        let name = inst
124            .config
125            .get("name")
126            .and_then(|v| v.as_str())
127            .ok_or_else(|| {
128                format!(
129                    "policy '{}': supernode node '{}' is missing config.name",
130                    policy.name, inst.id
131                )
132            })?;
133        let def = *by_name.get(name).ok_or_else(|| {
134            format!(
135                "policy '{}': node '{}' references unknown supernode '{}'",
136                policy.name, inst.id, name
137            )
138        })?;
139
140        // Reject nested supernodes (V1 limitation).
141        if def.nodes.iter().any(|n| n.node_type == "supernode") {
142            return Err(format!(
143                "policy '{}': supernode '{}' contains nested supernode; nesting not supported in V1",
144                policy.name, def.name
145            ));
146        }
147
148        // Build boundary_map: node id -> boundary type.
149        let mut boundary_map = HashMap::new();
150        for n in &def.nodes {
151            if BOUNDARY_TYPES.contains(&n.node_type.as_str()) {
152                boundary_map.insert(n.id.clone(), n.node_type.clone());
153            }
154        }
155
156        // Find the input boundary node (by type, not literal id).
157        let input_node_id = boundary_map
158            .iter()
159            .find(|(_, ty)| ty.as_str() == "input")
160            .map(|(id, _)| id.clone())
161            .ok_or_else(|| format!("supernode '{}' has no input boundary node", def.name))?;
162
163        // Find the target of input.out edge.
164        let entry_edge = def
165            .edges
166            .iter()
167            .find(|e| split_endpoint(&e.from).0 == input_node_id.as_str())
168            .ok_or_else(|| {
169                format!(
170                    "supernode '{}' has no edge from input node '{}'",
171                    def.name,
172                    input_node_id.as_str()
173                )
174            })?;
175        let (entry_target, _) = split_endpoint(&entry_edge.to);
176
177        // Check if entry target is a boundary (pass-through case).
178        let (entry, pass_through_boundary) = if boundary_map.contains_key(entry_target) {
179            (None, Some(entry_target.to_string()))
180        } else {
181            (Some(format!("{}/{}.in", inst.id, entry_target)), None)
182        };
183
184        let output_ids: Vec<String> = def
185            .nodes
186            .iter()
187            .filter(|n| n.node_type == "output")
188            .map(|n| n.id.clone())
189            .collect();
190        let error_ids: Vec<String> = def
191            .nodes
192            .iter()
193            .filter(|n| n.node_type == "error")
194            .map(|n| n.id.clone())
195            .collect();
196
197        let mut exit_to: HashMap<String, String> = HashMap::new();
198        for e in &policy.edges {
199            let (from_node, from_port) = split_endpoint(&e.from);
200            if from_node != inst.id {
201                continue;
202            }
203            let port = if from_port == "out" {
204                "success"
205            } else {
206                from_port
207            };
208            let boundary_id = output_ids
209                .iter()
210                .find(|id| port_for_output_boundary(id) == port)
211                .or_else(|| error_ids.iter().find(|id| id.as_str() == port))
212                .cloned();
213            let Some(bid) = boundary_id else {
214                let mut exposed: Vec<&str> = output_ids
215                    .iter()
216                    .map(|id| port_for_output_boundary(id))
217                    .collect();
218                exposed.extend(error_ids.iter().map(|id| id.as_str()));
219                return Err(format!(
220                    "policy '{}': unknown port '{}' on supernode instance '{}' — \
221                     supernode '{}' exposes: {}",
222                    policy.name,
223                    from_port,
224                    inst.id,
225                    def.name,
226                    exposed.join(", ")
227                ));
228            };
229            if exit_to.insert(bid, e.to.clone()).is_some() {
230                return Err(format!(
231                    "policy '{}': duplicate edge from supernode instance '{}' port '{}' — \
232                     each instance port accepts one edge",
233                    policy.name, inst.id, port
234                ));
235            }
236        }
237
238        // Every output-derived port is mandatory-wired, matching the compile-time
239        // rule for plugin nodes (engine.rs). The instance node is gone before
240        // compile runs, so this is the only place a clear error can be raised.
241        for oid in &output_ids {
242            if !exit_to.contains_key(oid.as_str()) {
243                let port = port_for_output_boundary(oid);
244                return Err(format!(
245                    "policy '{}': output port '{}' of supernode instance '{}' must be \
246                     wired — add an edge from '{}.{}'",
247                    policy.name, port, inst.id, inst.id, port
248                ));
249            }
250        }
251
252        splices.insert(
253            inst.id.as_str(),
254            Splice {
255                def,
256                boundary_map,
257                input_node_id,
258                entry,
259                pass_through_boundary,
260                exit_to,
261            },
262        );
263    }
264
265    // Resolves `target` (an edge endpoint) to the concrete endpoint it
266    // ultimately reaches, iterating through chained pass-through instances
267    // to a fixed point (a non-instance endpoint, or a normal instance's
268    // already-resolved entry). `Ok(None)` means the chain terminates in an
269    // unwired outer port, so the edge referencing `target` must be dropped.
270    // `Err` means the chain cycles back on an instance already visited.
271    fn resolve_target(
272        splices: &HashMap<&str, Splice>,
273        target: &str,
274    ) -> Result<Option<String>, String> {
275        let mut current = target.to_string();
276        // Ordered so a cycle error can name the full loop, not just the
277        // repeated node.
278        let mut path: Vec<String> = Vec::new();
279        loop {
280            let (target_node, _) = split_endpoint(&current);
281            let s = match splices.get(target_node) {
282                Some(s) => s,
283                None => return Ok(Some(current)),
284            };
285            if let Some(entry) = &s.entry {
286                // Normal instance: entry is already a concrete inlined node.
287                return Ok(Some(entry.clone()));
288            }
289            let pass_boundary = match &s.pass_through_boundary {
290                Some(b) => b,
291                None => return Ok(Some(current)),
292            };
293            if path.iter().any(|p| p == target_node) {
294                path.push(target_node.to_string());
295                return Err(format!(
296                    "supernode pass-through cycle: {}",
297                    path.join(" -> ")
298                ));
299            }
300            path.push(target_node.to_string());
301            match s.exit_to.get(pass_boundary.as_str()) {
302                Some(t) => current = t.clone(),
303                None => return Ok(None), // only reachable for an unwired error-kind boundary
304            }
305        }
306    }
307
308    // Proactively walk every pass-through instance's chain so a cycle is
309    // reported even if no outer edge happens to traverse it.
310    for (id, s) in &splices {
311        if s.pass_through_boundary.is_some() {
312            resolve_target(&splices, &format!("{id}.in"))
313                .map_err(|err| format!("policy '{}': {}", policy.name, err))?;
314        }
315    }
316
317    // Non-instance nodes survive as-is; instance nodes are replaced below.
318    let mut nodes: Vec<NodeConfig> = policy
319        .nodes
320        .iter()
321        .filter(|n| n.node_type != "supernode")
322        .cloned()
323        .collect();
324
325    // Outer edges: those leaving an instance are replaced by inner exit
326    // edges; those entering one are redirected to its resolved entry point,
327    // following chained pass-throughs to a fixed point.
328    let mut edges: Vec<EdgeConfig> = Vec::new();
329    for e in &policy.edges {
330        let (from_node, _) = split_endpoint(&e.from);
331        if splices.contains_key(from_node) {
332            continue;
333        }
334        let (to_node, _) = split_endpoint(&e.to);
335        if splices.contains_key(to_node) {
336            if let Some(resolved) = resolve_target(&splices, &e.to)
337                .map_err(|err| format!("policy '{}': {}", policy.name, err))?
338            {
339                edges.push(EdgeConfig {
340                    from: e.from.clone(),
341                    to: resolved,
342                });
343            }
344        } else {
345            edges.push(e.clone());
346        }
347    }
348
349    for inst in &instances {
350        let s = &splices[inst.id.as_str()];
351        if s.pass_through_boundary.is_some() {
352            // Pass-through supernode: skip inner edge processing; they're handled above.
353            continue;
354        }
355
356        let prefix = |id: &str| format!("{}/{}", inst.id, id);
357
358        // Inner nodes whose error port is wired inside the definition.
359        let handled_errors: HashSet<&str> = s
360            .def
361            .edges
362            .iter()
363            .filter(|e| split_endpoint(&e.from).1 == "error")
364            .map(|e| split_endpoint(&e.from).0)
365            .collect();
366
367        for n in &s.def.nodes {
368            if BOUNDARY_TYPES.contains(&n.node_type.as_str()) {
369                continue;
370            }
371            let mut inlined = n.clone();
372            inlined.id = prefix(&n.id);
373            inlined.position = None;
374            nodes.push(inlined);
375        }
376
377        for e in &s.def.edges {
378            let (from_node, from_port) = split_endpoint(&e.from);
379            let (to_node, to_port) = split_endpoint(&e.to);
380            if from_node == s.input_node_id.as_str() {
381                continue; // spliced via the outer in-edge above
382            }
383            let from = format!("{}.{}", prefix(from_node), from_port);
384
385            // Determine whether the target is a boundary node using boundary_map.
386            let to_is_boundary = matches!(
387                s.boundary_map.get(to_node).map(|t| t.as_str()),
388                Some("output") | Some("error")
389            );
390            if to_is_boundary {
391                if let Some(t) = s.exit_to.get(to_node) {
392                    if let Some(resolved) = resolve_target(&splices, t)
393                        .map_err(|err| format!("policy '{}': {}", policy.name, err))?
394                    {
395                        edges.push(EdgeConfig { from, to: resolved });
396                    }
397                }
398                // Unwired error-kind boundary: drop (policy catch-all takes over).
399                // Output boundaries are always wired — checked above.
400            } else {
401                edges.push(EdgeConfig {
402                    from,
403                    to: format!("{}.{}", prefix(to_node), to_port),
404                });
405            }
406        }
407
408        // Black-box guarantee: unwired inner error ports exit through the
409        // instance's DEFAULT error output — only the `error`-id boundary
410        // carries this guarantee. `exit_to` can only hold key "error" when
411        // the definition has an `error`-id error boundary and the policy
412        // wired it: output ids can't be `error` (reserved), and the lookup
413        // above inserts entries keyed by boundary id.
414        if let Some(t) = s.exit_to.get("error") {
415            if let Some(resolved) = resolve_target(&splices, t)
416                .map_err(|err| format!("policy '{}': {}", policy.name, err))?
417            {
418                for n in &s.def.nodes {
419                    if BOUNDARY_TYPES.contains(&n.node_type.as_str())
420                        || handled_errors.contains(n.id.as_str())
421                    {
422                        continue;
423                    }
424                    edges.push(EdgeConfig {
425                        from: format!("{}.error", prefix(&n.id)),
426                        to: resolved.clone(),
427                    });
428                }
429            }
430        }
431    }
432
433    Ok(PolicyConfig {
434        name: policy.name.clone(),
435        error_handler: policy.error_handler.clone(),
436        nodes,
437        edges,
438    })
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use std::collections::HashMap as Map;
445
446    fn node(id: &str, ty: &str) -> NodeConfig {
447        NodeConfig {
448            id: id.into(),
449            node_type: ty.into(),
450            config: Map::new(),
451            config_ref: None,
452            position: None,
453        }
454    }
455
456    fn supernode_instance(id: &str, name: &str) -> NodeConfig {
457        let mut n = node(id, "supernode");
458        n.config.insert("name".into(), serde_json::json!(name));
459        n
460    }
461
462    fn edge(from: &str, to: &str) -> EdgeConfig {
463        EdgeConfig {
464            from: from.into(),
465            to: to.into(),
466        }
467    }
468
469    /// auth -> up, auth errors exit via the error boundary, up.success exits
470    /// via output; up's error port is deliberately unwired (black-box test).
471    fn secured_call() -> SupernodeConfig {
472        SupernodeConfig {
473            name: "secured-call".into(),
474            description: None,
475            nodes: vec![
476                node("input", "input"),
477                node("output", "output"),
478                node("error", "error"),
479                node("auth", "key-auth"),
480                node("up", "upstream"),
481            ],
482            edges: vec![
483                edge("input.out", "auth.in"),
484                edge("auth.success", "up.in"),
485                edge("auth.error", "error.in"),
486                edge("up.success", "output.in"),
487            ],
488        }
489    }
490
491    fn policy_using(instance: &str) -> PolicyConfig {
492        PolicyConfig {
493            name: "p".into(),
494            error_handler: None,
495            nodes: vec![
496                node("listener", "listener"),
497                supernode_instance(instance, "secured-call"),
498                node("eh", "error-handler"),
499                node("client", "client"),
500            ],
501            edges: vec![
502                edge("listener.out", &format!("{instance}.in")),
503                edge(&format!("{instance}.success"), "client.in"),
504                edge(&format!("{instance}.error"), "eh.in"),
505                edge("eh.success", "client.in"),
506            ],
507        }
508    }
509
510    fn edge_set(p: &PolicyConfig) -> Vec<String> {
511        let mut v: Vec<String> = p
512            .edges
513            .iter()
514            .map(|e| format!("{}->{}", e.from, e.to))
515            .collect();
516        v.sort();
517        v
518    }
519
520    #[test]
521    fn test_policy_without_instances_is_unchanged() {
522        let p = PolicyConfig {
523            name: "plain".into(),
524            error_handler: None,
525            nodes: vec![node("listener", "listener"), node("client", "client")],
526            edges: vec![edge("listener.out", "client.in")],
527        };
528        let out = expand_policy(&p, &[secured_call()]).unwrap();
529        assert_eq!(out.nodes.len(), 2);
530        assert_eq!(edge_set(&out), vec!["listener.out->client.in"]);
531    }
532
533    #[test]
534    fn test_happy_path_inlines_and_splices() {
535        let out = expand_policy(&policy_using("sec"), &[secured_call()]).unwrap();
536
537        let ids: HashSet<&str> = out.nodes.iter().map(|n| n.id.as_str()).collect();
538        assert!(ids.contains("sec/auth") && ids.contains("sec/up"));
539        assert!(!ids.contains("sec"), "instance node must be removed");
540        assert!(
541            !out.nodes
542                .iter()
543                .any(|n| BOUNDARY_TYPES.contains(&n.node_type.as_str())),
544            "boundary pseudo-nodes must not leak into the expanded policy"
545        );
546
547        assert_eq!(
548            edge_set(&out),
549            vec![
550                "eh.success->client.in",
551                "listener.out->sec/auth.in",   // outer in-edge -> entry
552                "sec/auth.error->eh.in",       // error boundary -> outer error target
553                "sec/auth.success->sec/up.in", // inner edge, prefixed
554                "sec/up.error->eh.in",         // implicit black-box error edge
555                "sec/up.success->client.in",   // output boundary -> outer success target
556            ]
557        );
558    }
559
560    /// The `success` output port is now mandatory-wired; leaving it unwired
561    /// is a hard error naming the instance and port.
562    #[test]
563    fn test_unwired_success_exit_is_rejected() {
564        let mut p = policy_using("sec");
565        p.edges.retain(|e| !e.from.starts_with("sec.")); // keep only listener->sec.in, eh edge
566        let err = expand_policy(&p, &[secured_call()]).unwrap_err();
567        assert!(
568            err.contains("output port 'success' of supernode instance 'sec' must be wired"),
569            "got: {err}"
570        );
571    }
572
573    /// An unwired **error** exit still just drops edges: no implicit error
574    /// edges are added, but the rest of the chain (success) still splices.
575    #[test]
576    fn test_unwired_error_exit_drops_edges() {
577        let mut p = policy_using("sec");
578        p.edges.retain(|e| e.from != "sec.error"); // drop only sec.error edge
579        p.edges.retain(|e| !e.from.starts_with("eh.")); // now-orphaned eh edge
580        p.nodes.retain(|n| n.id != "eh"); // now-orphaned eh node
581        let out = expand_policy(&p, &[secured_call()]).unwrap();
582        assert!(!out
583            .edges
584            .iter()
585            .any(|e| e.from.starts_with("sec/auth.error")));
586        assert!(!out.edges.iter().any(|e| e.from.starts_with("sec/up.error")));
587        assert!(out
588            .edges
589            .iter()
590            .any(|e| e.from == "sec/up.success" && e.to == "client.in"));
591    }
592
593    #[test]
594    fn test_two_instances_of_same_supernode_get_distinct_namespaces() {
595        let p = PolicyConfig {
596            name: "p2".into(),
597            error_handler: None,
598            nodes: vec![
599                node("listener", "listener"),
600                supernode_instance("a", "secured-call"),
601                supernode_instance("b", "secured-call"),
602                node("client", "client"),
603            ],
604            edges: vec![
605                edge("listener.out", "a.in"),
606                edge("a.success", "b.in"),
607                edge("b.success", "client.in"),
608            ],
609        };
610        let out = expand_policy(&p, &[secured_call()]).unwrap();
611        let ids: HashSet<&str> = out.nodes.iter().map(|n| n.id.as_str()).collect();
612        for id in ["a/auth", "a/up", "b/auth", "b/up"] {
613            assert!(ids.contains(id), "missing {id}");
614        }
615        // a's output boundary must splice into b's entry node.
616        assert!(out
617            .edges
618            .iter()
619            .any(|e| e.from == "a/up.success" && e.to == "b/auth.in"));
620    }
621
622    #[test]
623    fn test_unknown_supernode_is_an_error() {
624        let p = PolicyConfig {
625            name: "p".into(),
626            error_handler: None,
627            nodes: vec![
628                node("listener", "listener"),
629                supernode_instance("sec", "nope"),
630                node("client", "client"),
631            ],
632            edges: vec![
633                edge("listener.out", "sec.in"),
634                edge("sec.success", "client.in"),
635            ],
636        };
637        let err = expand_policy(&p, &[secured_call()]).unwrap_err();
638        assert!(err.contains("unknown supernode 'nope'"), "got: {err}");
639        assert!(err.contains("'sec'"), "got: {err}");
640    }
641
642    #[test]
643    fn test_missing_config_name_is_an_error() {
644        let p = PolicyConfig {
645            name: "p".into(),
646            error_handler: None,
647            nodes: vec![
648                node("listener", "listener"),
649                node("sec", "supernode"), // no config.name
650                node("client", "client"),
651            ],
652            edges: vec![
653                edge("listener.out", "sec.in"),
654                edge("sec.success", "client.in"),
655            ],
656        };
657        let err = expand_policy(&p, &[secured_call()]).unwrap_err();
658        assert!(err.contains("missing config.name"), "got: {err}");
659    }
660
661    /// Positions are UI-only and meaningless after inlining.
662    #[test]
663    fn test_inner_positions_are_dropped() {
664        let mut def = secured_call();
665        for n in &mut def.nodes {
666            n.position = Some(crate::config::Position { x: 1.0, y: 2.0 });
667        }
668        let out = expand_policy(&policy_using("sec"), &[def]).unwrap();
669        assert!(out
670            .nodes
671            .iter()
672            .filter(|n| n.id.starts_with("sec/"))
673            .all(|n| n.position.is_none()));
674    }
675
676    /// Pass-through identity supernode: input.out -> output.in (minimal).
677    /// UI seeds newly created supernodes exactly like this.
678    fn identity_supernode() -> SupernodeConfig {
679        SupernodeConfig {
680            name: "identity".into(),
681            description: None,
682            nodes: vec![
683                node("input", "input"),
684                node("output", "output"),
685                node("error", "error"),
686            ],
687            edges: vec![edge("input.out", "output.in")],
688        }
689    }
690
691    #[test]
692    fn test_pass_through_identity_with_wired_outer_success() {
693        let p = PolicyConfig {
694            name: "p".into(),
695            error_handler: None,
696            nodes: vec![
697                node("listener", "listener"),
698                supernode_instance("pass", "identity"),
699                node("client", "client"),
700            ],
701            edges: vec![
702                edge("listener.out", "pass.in"),
703                edge("pass.success", "client.in"),
704            ],
705        };
706        let out = expand_policy(&p, &[identity_supernode()]).unwrap();
707        // Instance node must be removed; no inner nodes inlined.
708        assert!(!out.nodes.iter().any(|n| n.id.contains("pass")));
709        // Outer edge redirected to outer success target.
710        assert!(out
711            .edges
712            .iter()
713            .any(|e| e.from == "listener.out" && e.to == "client.in"));
714    }
715
716    #[test]
717    fn test_pass_through_identity_with_unwired_outer_success_is_rejected() {
718        let p = PolicyConfig {
719            name: "p".into(),
720            error_handler: None,
721            nodes: vec![
722                node("listener", "listener"),
723                supernode_instance("pass", "identity"),
724                node("client", "client"),
725            ],
726            edges: vec![
727                edge("listener.out", "pass.in"),
728                edge("pass.error", "client.in"),
729            ],
730        };
731        let err = expand_policy(&p, &[identity_supernode()]).unwrap_err();
732        assert!(
733            err.contains("output port 'success' of supernode instance 'pass'"),
734            "got: {err}"
735        );
736    }
737
738    #[test]
739    fn test_pass_through_cycle_is_error() {
740        let p = PolicyConfig {
741            name: "p".into(),
742            error_handler: None,
743            nodes: vec![
744                node("listener", "listener"),
745                supernode_instance("a", "identity"),
746                supernode_instance("b", "identity"),
747                node("client", "client"),
748            ],
749            edges: vec![
750                edge("listener.out", "a.in"),
751                edge("a.success", "b.in"),
752                edge("b.success", "a.in"), // Cycle: b -> a -> b
753                edge("a.error", "client.in"),
754            ],
755        };
756        let err = expand_policy(&p, &[identity_supernode()]).unwrap_err();
757        assert!(err.contains("pass-through cycle"), "got: {err}");
758        assert!(err.contains("a") && err.contains("b"), "got: {err}");
759    }
760
761    /// Boundary with a non-`output` id: node type is "output" but id is
762    /// "out1" — the instance port name IS the boundary id, not "success".
763    #[test]
764    fn test_named_output_boundary_port_name_is_its_id() {
765        let def = SupernodeConfig {
766            name: "custom-boundary".into(),
767            description: None,
768            nodes: vec![
769                node("input", "input"),
770                node("out1", "output"), // Mismatched: id != type
771                node("err1", "error"),
772                node("process", "key-auth"),
773            ],
774            edges: vec![
775                edge("input.out", "process.in"),
776                edge("process.success", "out1.in"),
777            ],
778        };
779        let p = PolicyConfig {
780            name: "p".into(),
781            error_handler: None,
782            nodes: vec![
783                node("listener", "listener"),
784                supernode_instance("sb", "custom-boundary"),
785                node("client", "client"),
786            ],
787            edges: vec![edge("listener.out", "sb.in"), edge("sb.out1", "client.in")],
788        };
789        let out = expand_policy(&p, &[def]).unwrap();
790        // Edge from process.success -> out1.in must splice to client.in.
791        assert!(
792            out.edges
793                .iter()
794                .any(|e| e.from == "sb/process.success" && e.to == "client.in"),
795            "edges: {:?}",
796            out.edges
797                .iter()
798                .map(|e| format!("{}->{}", e.from, e.to))
799                .collect::<Vec<_>>()
800        );
801    }
802
803    /// Definition with a custom-named outcome port from an inner node to
804    /// the `output` boundary: input -> auth -> ...; auth.denied -> output.
805    /// (Until Task 8 lands no plugin type declares "denied"; expansion is
806    /// syntactic and must not care whether the port is declared.)
807    fn outcome_port_supernode() -> SupernodeConfig {
808        SupernodeConfig {
809            name: "outcome-def".into(),
810            description: None,
811            nodes: vec![
812                node("input", "input"),
813                node("output", "output"),
814                node("error", "error"),
815                node("auth", "key-auth"),
816            ],
817            edges: vec![
818                edge("input.out", "auth.in"),
819                edge("auth.denied", "output.in"),
820            ],
821        }
822    }
823
824    /// A named outcome port on an inner node survives expansion with the
825    /// instance prefix, targeting the node the definition wired it to.
826    #[test]
827    fn test_inner_outcome_port_is_prefixed_and_preserved() {
828        let p = PolicyConfig {
829            name: "p".into(),
830            error_handler: None,
831            nodes: vec![
832                node("listener", "listener"),
833                supernode_instance("sec", "outcome-def"),
834                node("eh", "error-handler"),
835                node("client", "client"),
836            ],
837            edges: vec![
838                edge("listener.out", "sec.in"),
839                edge("sec.success", "client.in"),
840                edge("sec.error", "eh.in"),
841            ],
842        };
843        let out = expand_policy(&p, &[outcome_port_supernode()]).unwrap();
844        assert_eq!(
845            edge_set(&out),
846            vec![
847                "listener.out->sec/auth.in",
848                "sec/auth.denied->client.in", // custom port, prefixed, follows output boundary
849                "sec/auth.error->eh.in",      // black-box: auth has no error edge of its own
850            ]
851        );
852    }
853
854    /// An inner outcome port wired to the `output` boundary follows the outer
855    /// success edge, same as inner success ports do today — even when the
856    /// outer success and error targets are distinct nodes, "denied" must
857    /// land on the success target, never the error one.
858    #[test]
859    fn test_inner_outcome_port_to_output_boundary() {
860        let p = PolicyConfig {
861            name: "p".into(),
862            error_handler: None,
863            nodes: vec![
864                node("listener", "listener"),
865                supernode_instance("sec", "outcome-def"),
866                node("eh", "error-handler"),
867                node("client", "client"),
868            ],
869            edges: vec![
870                edge("listener.out", "sec.in"),
871                edge("sec.success", "client.in"),
872                edge("sec.error", "eh.in"),
873            ],
874        };
875        let out = expand_policy(&p, &[outcome_port_supernode()]).unwrap();
876        assert!(
877            out.edges
878                .iter()
879                .any(|e| e.from == "sec/auth.denied" && e.to == "client.in"),
880            "edges: {:?}",
881            out.edges
882                .iter()
883                .map(|e| format!("{}->{}", e.from, e.to))
884                .collect::<Vec<_>>()
885        );
886        assert!(
887            !out.edges
888                .iter()
889                .any(|e| e.from == "sec/auth.denied" && e.to == "eh.in"),
890            "custom outcome port must not be misrouted to the error target"
891        );
892    }
893
894    /// A custom-named port on the OUTER edge leaving a supernode instance
895    /// itself (`sec.denied -> ...`, as opposed to a port on an inner node)
896    /// must be rejected. An instance exposes only the two exits its boundary
897    /// pseudo-nodes define; outcome ports live on inner nodes and are wired
898    /// to a boundary *inside* the definition. Silently treating an unknown
899    /// name as `success` would let a typo (or a genuinely wrong port) rewire
900    /// the whole subgraph's success exit, and the instance node is gone by
901    /// the time compile-time port validation runs, so nothing downstream
902    /// could catch it.
903    #[test]
904    fn test_outer_custom_port_on_instance_is_rejected() {
905        let p = PolicyConfig {
906            name: "p".into(),
907            error_handler: None,
908            nodes: vec![
909                node("listener", "listener"),
910                supernode_instance("sec", "secured-call"),
911                node("client", "client"),
912            ],
913            edges: vec![
914                edge("listener.out", "sec.in"),
915                edge("sec.denied", "client.in"), // custom port, not "success"/"out"/"error"
916            ],
917        };
918        let err = expand_policy(&p, &[secured_call()]).unwrap_err();
919        assert!(
920            err.contains("unknown port 'denied'")
921                && err.contains("supernode instance 'sec'")
922                && err.contains("success"),
923            "got: {err}"
924        );
925    }
926
927    /// `out` is the documented YAML alias for `success` on an instance's exit
928    /// and must keep working.
929    #[test]
930    fn test_outer_out_alias_on_instance_is_accepted() {
931        let p = PolicyConfig {
932            name: "p".into(),
933            error_handler: None,
934            nodes: vec![
935                node("listener", "listener"),
936                supernode_instance("sec", "secured-call"),
937                node("client", "client"),
938            ],
939            edges: vec![edge("listener.out", "sec.in"), edge("sec.out", "client.in")],
940        };
941        let out = expand_policy(&p, &[secured_call()]).unwrap();
942        assert!(
943            out.edges
944                .iter()
945                .any(|e| e.from == "sec/up.success" && e.to == "client.in"),
946            "edges: {:?}",
947            out.edges
948                .iter()
949                .map(|e| format!("{}->{}", e.from, e.to))
950                .collect::<Vec<_>>()
951        );
952    }
953
954    /// Two success-flavoured outer edges (`sec.success` + `sec.out`) used to
955    /// silently last-write-wins, dropping one of them. Reject the duplicate.
956    #[test]
957    fn test_duplicate_success_outer_edge_on_instance_is_rejected() {
958        let p = PolicyConfig {
959            name: "p".into(),
960            error_handler: None,
961            nodes: vec![
962                node("listener", "listener"),
963                supernode_instance("sec", "secured-call"),
964                node("eh", "error-handler"),
965                node("client", "client"),
966            ],
967            edges: vec![
968                edge("listener.out", "sec.in"),
969                edge("sec.success", "client.in"),
970                edge("sec.out", "eh.in"), // second success-flavoured exit
971            ],
972        };
973        let err = expand_policy(&p, &[secured_call()]).unwrap_err();
974        assert!(
975            err.contains("duplicate edge") && err.contains("'sec'"),
976            "got: {err}"
977        );
978    }
979
980    /// Same for two `error` exits.
981    #[test]
982    fn test_duplicate_error_outer_edge_on_instance_is_rejected() {
983        let p = PolicyConfig {
984            name: "p".into(),
985            error_handler: None,
986            nodes: vec![
987                node("listener", "listener"),
988                supernode_instance("sec", "secured-call"),
989                node("eh", "error-handler"),
990                node("client", "client"),
991            ],
992            edges: vec![
993                edge("listener.out", "sec.in"),
994                edge("sec.success", "client.in"),
995                edge("sec.error", "eh.in"),
996                edge("sec.error", "client.in"),
997            ],
998        };
999        let err = expand_policy(&p, &[secured_call()]).unwrap_err();
1000        assert!(
1001            err.contains("duplicate edge") && err.contains("error"),
1002            "got: {err}"
1003        );
1004    }
1005
1006    #[test]
1007    fn test_nested_supernode_is_error() {
1008        let nested_def = SupernodeConfig {
1009            name: "nested".into(),
1010            description: None,
1011            nodes: vec![
1012                node("input", "input"),
1013                node("output", "output"),
1014                node("error", "error"),
1015                supernode_instance("inner", "identity"), // Nested supernode!
1016            ],
1017            edges: vec![
1018                edge("input.out", "inner.in"),
1019                edge("inner.success", "output.in"),
1020            ],
1021        };
1022        let p = PolicyConfig {
1023            name: "p".into(),
1024            error_handler: None,
1025            nodes: vec![
1026                node("listener", "listener"),
1027                supernode_instance("n", "nested"),
1028                node("client", "client"),
1029            ],
1030            edges: vec![edge("listener.out", "n.in"), edge("n.success", "client.in")],
1031        };
1032        let err = expand_policy(&p, &[nested_def, identity_supernode()]).unwrap_err();
1033        assert!(
1034            err.contains("nested") && err.contains("nesting not supported"),
1035            "got: {err}"
1036        );
1037    }
1038
1039    /// Error-boundary pass-through: input.out -> error.in (F3 fix).
1040    fn error_pass_through() -> SupernodeConfig {
1041        SupernodeConfig {
1042            name: "error-passthrough".into(),
1043            description: None,
1044            nodes: vec![
1045                node("input", "input"),
1046                node("output", "output"),
1047                node("error", "error"),
1048            ],
1049            edges: vec![edge("input.out", "error.in")],
1050        }
1051    }
1052
1053    #[test]
1054    fn test_error_boundary_pass_through_with_wired_outer_error() {
1055        let p = PolicyConfig {
1056            name: "p".into(),
1057            error_handler: None,
1058            nodes: vec![
1059                node("listener", "listener"),
1060                supernode_instance("err_pass", "error-passthrough"),
1061                node("eh", "error-handler"),
1062                node("client", "client"),
1063            ],
1064            edges: vec![
1065                edge("listener.out", "err_pass.in"),
1066                edge("err_pass.error", "eh.in"),
1067                edge("err_pass.success", "client.in"),
1068            ],
1069        };
1070        let out = expand_policy(&p, &[error_pass_through()]).unwrap();
1071        // Instance node must be removed; no inner nodes inlined.
1072        assert!(!out.nodes.iter().any(|n| n.id.contains("err_pass")));
1073        // Outer in-edge must be redirected to error target.
1074        assert!(
1075            out.edges
1076                .iter()
1077                .any(|e| e.from == "listener.out" && e.to == "eh.in"),
1078            "edges: {:?}",
1079            out.edges
1080                .iter()
1081                .map(|e| format!("{}->{}", e.from, e.to))
1082                .collect::<Vec<_>>()
1083        );
1084    }
1085
1086    #[test]
1087    fn test_error_boundary_pass_through_with_unwired_outer_error() {
1088        let p = PolicyConfig {
1089            name: "p".into(),
1090            error_handler: None,
1091            nodes: vec![
1092                node("listener", "listener"),
1093                supernode_instance("err_pass", "error-passthrough"),
1094                node("client", "client"),
1095            ],
1096            edges: vec![
1097                edge("listener.out", "err_pass.in"),
1098                edge("err_pass.success", "client.in"), // Only success wired, not error
1099            ],
1100        };
1101        let out = expand_policy(&p, &[error_pass_through()]).unwrap();
1102        // Outer in-edge is dropped because error target is unwired.
1103        assert!(!out.edges.iter().any(|e| e.from == "listener.out"));
1104    }
1105
1106    /// Mismatched-id input boundary: {id: in1, type: input} (F4 fix).
1107    fn mismatched_input_id() -> SupernodeConfig {
1108        SupernodeConfig {
1109            name: "custom-input".into(),
1110            description: None,
1111            nodes: vec![
1112                node("in1", "input"), // id != type
1113                node("output", "output"),
1114                node("error", "error"),
1115                node("process", "upstream"),
1116            ],
1117            edges: vec![
1118                edge("in1.out", "process.in"),
1119                edge("process.success", "output.in"),
1120            ],
1121        }
1122    }
1123
1124    #[test]
1125    fn test_mismatched_id_input_boundary_expands_correctly() {
1126        let p = PolicyConfig {
1127            name: "p".into(),
1128            error_handler: None,
1129            nodes: vec![
1130                node("listener", "listener"),
1131                supernode_instance("custom", "custom-input"),
1132                node("client", "client"),
1133            ],
1134            edges: vec![
1135                edge("listener.out", "custom.in"),
1136                edge("custom.success", "client.in"),
1137            ],
1138        };
1139        let out = expand_policy(&p, &[mismatched_input_id()]).unwrap();
1140        // Inlining must work: process node should be present.
1141        let ids: HashSet<&str> = out.nodes.iter().map(|n| n.id.as_str()).collect();
1142        assert!(ids.contains("custom/process"), "process node not inlined");
1143        assert!(
1144            !ids.contains("custom/in1"),
1145            "input boundary should not be inlined"
1146        );
1147        // Edge from process.success must splice to client.in.
1148        assert!(out
1149            .edges
1150            .iter()
1151            .any(|e| e.from == "custom/process.success" && e.to == "client.in"));
1152        // No edge endpoint should reference the input boundary id "in1" (GAP B).
1153        assert!(
1154            !out.edges
1155                .iter()
1156                .any(|e| e.from.contains("in1") || e.to.contains("in1")),
1157            "no edge should reference input boundary id 'in1'; edges: {:?}",
1158            out.edges
1159                .iter()
1160                .map(|e| format!("{}->{}", e.from, e.to))
1161                .collect::<Vec<_>>()
1162        );
1163    }
1164
1165    /// Chain through pass-through into another instance (GAP A).
1166    /// Verifies that targets passed through splices are resolved via resolve_target.
1167    #[test]
1168    fn test_pass_through_chain_into_another_instance() {
1169        // x is an error pass-through, y is normal
1170        let p = PolicyConfig {
1171            name: "p".into(),
1172            error_handler: None,
1173            nodes: vec![
1174                node("listener", "listener"),
1175                supernode_instance("x", "error-passthrough"),
1176                supernode_instance("y", "secured-call"),
1177                node("eh", "error-handler"),
1178            ],
1179            edges: vec![
1180                edge("listener.out", "x.in"),
1181                edge("x.error", "y.in"), // x's error target is y's input
1182                edge("y.success", "eh.in"),
1183                edge("x.success", "eh.in"),
1184            ],
1185        };
1186        let out = expand_policy(&p, &[error_pass_through(), secured_call()]).unwrap();
1187        // Verify that listener.out is spliced to y's entry (y/auth.in), not y.in.
1188        assert!(
1189            out.edges
1190                .iter()
1191                .any(|e| e.from == "listener.out" && e.to == "y/auth.in"),
1192            "listener.out should splice to y/auth.in; edges: {:?}",
1193            out.edges
1194                .iter()
1195                .map(|e| format!("{}->{}", e.from, e.to))
1196                .collect::<Vec<_>>()
1197        );
1198        // y.in should NOT appear as a target (only y/auth.in).
1199        assert!(
1200            !out.edges.iter().any(|e| e.to == "y.in"),
1201            "no edge should target instance port y.in (dangling); edges: {:?}",
1202            out.edges
1203                .iter()
1204                .map(|e| format!("{}->{}", e.from, e.to))
1205                .collect::<Vec<_>>()
1206        );
1207    }
1208
1209    /// Multi-hop pass-through chain (Round-3 re-review Finding 1): x and y
1210    /// are both error-pass-throughs, z is a normal instance. resolve_target
1211    /// must iterate through both pass-through hops to reach z's real entry,
1212    /// not stop after resolving just one hop.
1213    #[test]
1214    fn test_multi_hop_pass_through_chain_resolves_to_fixed_point() {
1215        let p = PolicyConfig {
1216            name: "p".into(),
1217            error_handler: None,
1218            nodes: vec![
1219                node("listener", "listener"),
1220                supernode_instance("x", "error-passthrough"),
1221                supernode_instance("y", "error-passthrough"),
1222                supernode_instance("z", "secured-call"),
1223                node("eh", "error-handler"),
1224            ],
1225            edges: vec![
1226                edge("listener.out", "x.in"),
1227                edge("x.error", "y.in"),
1228                edge("y.error", "z.in"),
1229                edge("z.success", "eh.in"),
1230                edge("x.success", "eh.in"),
1231                edge("y.success", "eh.in"),
1232            ],
1233        };
1234        let out = expand_policy(&p, &[error_pass_through(), secured_call()]).unwrap();
1235        let edge_strs: Vec<String> = out
1236            .edges
1237            .iter()
1238            .map(|e| format!("{}->{}", e.from, e.to))
1239            .collect();
1240        // listener.out must resolve all the way through x and y to z's real entry.
1241        assert!(
1242            out.edges
1243                .iter()
1244                .any(|e| e.from == "listener.out" && e.to == "z/auth.in"),
1245            "listener.out should splice to z/auth.in; edges: {edge_strs:?}"
1246        );
1247        // No edge may reference the bare (now-removed) instance ids.
1248        assert!(
1249            !out.edges.iter().any(|e| e.from == "x.in"
1250                || e.to == "x.in"
1251                || e.from == "y.in"
1252                || e.to == "y.in"
1253                || e.from == "z.in"
1254                || e.to == "z.in"),
1255            "no edge should reference a bare instance port; edges: {edge_strs:?}"
1256        );
1257    }
1258
1259    /// Cyclic multi-hop pass-through chain: x -> y -> x, both error-pass-throughs.
1260    /// resolve_target's cycle guard must catch this even though it takes two
1261    /// hops to loop back, not just an immediate self-reference.
1262    #[test]
1263    fn test_multi_hop_pass_through_cycle_is_error() {
1264        let p = PolicyConfig {
1265            name: "p".into(),
1266            error_handler: None,
1267            nodes: vec![
1268                node("listener", "listener"),
1269                supernode_instance("x", "error-passthrough"),
1270                supernode_instance("y", "error-passthrough"),
1271                node("client", "client"),
1272            ],
1273            edges: vec![
1274                edge("listener.out", "x.in"),
1275                edge("x.error", "y.in"),
1276                edge("y.error", "x.in"), // cycle: x -> y -> x
1277                edge("x.success", "client.in"),
1278                edge("y.success", "client.in"),
1279            ],
1280        };
1281        let err = expand_policy(&p, &[error_pass_through()]).unwrap_err();
1282        assert!(err.contains("cycle"), "got: {err}");
1283    }
1284
1285    /// gate definition with two output boundaries: `output` (success) and `denied`.
1286    fn named_ports_supernode() -> SupernodeConfig {
1287        SupernodeConfig {
1288            name: "gate".into(),
1289            description: None,
1290            nodes: vec![
1291                node("input", "input"),
1292                node("output", "output"),
1293                node("denied", "output"),
1294                node("error", "error"),
1295                node("auth", "key-auth"),
1296            ],
1297            edges: vec![
1298                edge("input.out", "auth.in"),
1299                edge("auth.success", "output.in"),
1300                edge("auth.denied", "denied.in"),
1301            ],
1302        }
1303    }
1304
1305    /// Named ports splice to their own outer targets: `gate.success` and
1306    /// `gate.denied` land on different nodes.
1307    #[test]
1308    fn test_named_output_ports_splice_to_distinct_targets() {
1309        let p = PolicyConfig {
1310            name: "p".into(),
1311            error_handler: None,
1312            nodes: vec![
1313                node("listener", "listener"),
1314                supernode_instance("gate", "gate"),
1315                node("reject", "error-handler"),
1316                node("client", "client"),
1317            ],
1318            edges: vec![
1319                edge("listener.out", "gate.in"),
1320                edge("gate.success", "client.in"),
1321                edge("gate.denied", "reject.in"),
1322                edge("reject.success", "client.in"),
1323            ],
1324        };
1325        let out = expand_policy(&p, &[named_ports_supernode()]).unwrap();
1326        assert_eq!(
1327            edge_set(&out),
1328            vec![
1329                "gate/auth.denied->reject.in",
1330                "gate/auth.success->client.in",
1331                "listener.out->gate/auth.in",
1332                "reject.success->client.in",
1333            ]
1334        );
1335    }
1336
1337    /// An unwired named port is a hard error naming the instance and port.
1338    #[test]
1339    fn test_unwired_named_port_is_rejected() {
1340        let p = PolicyConfig {
1341            name: "p".into(),
1342            error_handler: None,
1343            nodes: vec![
1344                node("listener", "listener"),
1345                supernode_instance("gate", "gate"),
1346                node("client", "client"),
1347            ],
1348            edges: vec![
1349                edge("listener.out", "gate.in"),
1350                edge("gate.success", "client.in"),
1351                // gate.denied deliberately unwired
1352            ],
1353        };
1354        let err = expand_policy(&p, &[named_ports_supernode()]).unwrap_err();
1355        assert!(
1356            err.contains("output port 'denied' of supernode instance 'gate' must be wired")
1357                && err.contains("add an edge from 'gate.denied'"),
1358            "got: {err}"
1359        );
1360    }
1361
1362    /// `success` is mandatory too when an `output`-id boundary exists.
1363    #[test]
1364    fn test_unwired_success_port_is_rejected() {
1365        let p = PolicyConfig {
1366            name: "p".into(),
1367            error_handler: None,
1368            nodes: vec![
1369                node("listener", "listener"),
1370                supernode_instance("gate", "gate"),
1371                node("reject", "error-handler"),
1372                node("client", "client"),
1373            ],
1374            edges: vec![
1375                edge("listener.out", "gate.in"),
1376                edge("gate.denied", "reject.in"),
1377                edge("reject.success", "client.in"),
1378            ],
1379        };
1380        let err = expand_policy(&p, &[named_ports_supernode()]).unwrap_err();
1381        assert!(
1382            err.contains("output port 'success' of supernode instance 'gate' must be wired"),
1383            "got: {err}"
1384        );
1385    }
1386
1387    /// A definition with only named outputs exposes no `success` port at all.
1388    fn named_only_supernode() -> SupernodeConfig {
1389        SupernodeConfig {
1390            name: "named-only".into(),
1391            description: None,
1392            nodes: vec![
1393                node("input", "input"),
1394                node("done", "output"),
1395                node("error", "error"),
1396                node("up", "upstream"),
1397            ],
1398            edges: vec![edge("input.out", "up.in"), edge("up.success", "done.in")],
1399        }
1400    }
1401
1402    #[test]
1403    fn test_success_rejected_when_no_output_id_boundary() {
1404        let p = PolicyConfig {
1405            name: "p".into(),
1406            error_handler: None,
1407            nodes: vec![
1408                node("listener", "listener"),
1409                supernode_instance("n", "named-only"),
1410                node("client", "client"),
1411            ],
1412            edges: vec![edge("listener.out", "n.in"), edge("n.success", "client.in")],
1413        };
1414        let err = expand_policy(&p, &[named_only_supernode()]).unwrap_err();
1415        assert!(
1416            err.contains("unknown port 'success'") && err.contains("done"),
1417            "unknown-port error must list the exposed ports; got: {err}"
1418        );
1419    }
1420
1421    #[test]
1422    fn test_named_only_supernode_routes_via_named_port() {
1423        let p = PolicyConfig {
1424            name: "p".into(),
1425            error_handler: None,
1426            nodes: vec![
1427                node("listener", "listener"),
1428                supernode_instance("n", "named-only"),
1429                node("client", "client"),
1430            ],
1431            edges: vec![edge("listener.out", "n.in"), edge("n.done", "client.in")],
1432        };
1433        let out = expand_policy(&p, &[named_only_supernode()]).unwrap();
1434        assert!(out
1435            .edges
1436            .iter()
1437            .any(|e| e.from == "n/up.success" && e.to == "client.in"));
1438    }
1439
1440    /// Pass-through via a NAMED boundary: input.out -> denied.in resolves the
1441    /// outer in-edge to the target wired on the instance's `denied` port.
1442    #[test]
1443    fn test_pass_through_via_named_boundary() {
1444        let def = SupernodeConfig {
1445            name: "shortcut".into(),
1446            description: None,
1447            nodes: vec![
1448                node("input", "input"),
1449                node("denied", "output"),
1450                node("error", "error"),
1451            ],
1452            edges: vec![edge("input.out", "denied.in")],
1453        };
1454        let p = PolicyConfig {
1455            name: "p".into(),
1456            error_handler: None,
1457            nodes: vec![
1458                node("listener", "listener"),
1459                supernode_instance("s", "shortcut"),
1460                node("client", "client"),
1461            ],
1462            edges: vec![edge("listener.out", "s.in"), edge("s.denied", "client.in")],
1463        };
1464        let out = expand_policy(&p, &[def]).unwrap();
1465        assert!(out
1466            .edges
1467            .iter()
1468            .any(|e| e.from == "listener.out" && e.to == "client.in"));
1469    }
1470
1471    /// Two edges on the same named port are duplicates.
1472    #[test]
1473    fn test_duplicate_named_port_outer_edge_is_rejected() {
1474        let p = PolicyConfig {
1475            name: "p".into(),
1476            error_handler: None,
1477            nodes: vec![
1478                node("listener", "listener"),
1479                supernode_instance("gate", "gate"),
1480                node("reject", "error-handler"),
1481                node("client", "client"),
1482            ],
1483            edges: vec![
1484                edge("listener.out", "gate.in"),
1485                edge("gate.success", "client.in"),
1486                edge("gate.denied", "reject.in"),
1487                edge("gate.denied", "client.in"),
1488                edge("reject.success", "client.in"),
1489            ],
1490        };
1491        let err = expand_policy(&p, &[named_ports_supernode()]).unwrap_err();
1492        assert!(
1493            err.contains("duplicate edge") && err.contains("'denied'"),
1494            "got: {err}"
1495        );
1496    }
1497
1498    /// gate with two error boundaries: `error` (default) and `auth-error`.
1499    /// `auth.error` exits via `auth-error`; `up` has no error edge (black-box).
1500    fn multi_error_supernode() -> SupernodeConfig {
1501        SupernodeConfig {
1502            name: "gate".into(),
1503            description: None,
1504            nodes: vec![
1505                node("input", "input"),
1506                node("output", "output"),
1507                node("error", "error"),
1508                node("auth-error", "error"),
1509                node("auth", "key-auth"),
1510                node("up", "upstream"),
1511            ],
1512            edges: vec![
1513                edge("input.out", "auth.in"),
1514                edge("auth.success", "up.in"),
1515                edge("auth.denied", "up.in"),
1516                edge("auth.error", "auth-error.in"),
1517                edge("up.success", "output.in"),
1518            ],
1519        }
1520    }
1521
1522    /// Named error ports route to their own targets; the black-box rule follows
1523    /// ONLY the default `error` port.
1524    #[test]
1525    fn test_named_error_port_and_default_black_box() {
1526        let p = PolicyConfig {
1527            name: "p".into(),
1528            error_handler: None,
1529            nodes: vec![
1530                node("listener", "listener"),
1531                supernode_instance("gate", "gate"),
1532                node("eh1", "error-handler"),
1533                node("eh2", "error-handler"),
1534                node("client", "client"),
1535            ],
1536            edges: vec![
1537                edge("listener.out", "gate.in"),
1538                edge("gate.success", "client.in"),
1539                edge("gate.error", "eh1.in"),
1540                edge("gate.auth-error", "eh2.in"),
1541                edge("eh1.success", "client.in"),
1542                edge("eh2.success", "client.in"),
1543            ],
1544        };
1545        let out = expand_policy(&p, &[multi_error_supernode()]).unwrap();
1546        // auth's own error edge follows the named boundary to eh2.
1547        assert!(out
1548            .edges
1549            .iter()
1550            .any(|e| e.from == "gate/auth.error" && e.to == "eh2.in"));
1551        // up has no error edge: black-box wires it to the DEFAULT error target.
1552        assert!(out
1553            .edges
1554            .iter()
1555            .any(|e| e.from == "gate/up.error" && e.to == "eh1.in"));
1556        // auth is handled inside the definition — no additional black-box edge.
1557        assert_eq!(
1558            out.edges
1559                .iter()
1560                .filter(|e| e.from == "gate/auth.error")
1561                .count(),
1562            1
1563        );
1564    }
1565
1566    /// Definition whose only error boundary is named `oops`: no `error` port
1567    /// exists, and there is NO implicit black-box wiring.
1568    fn renamed_error_supernode() -> SupernodeConfig {
1569        SupernodeConfig {
1570            name: "renamed".into(),
1571            description: None,
1572            nodes: vec![
1573                node("input", "input"),
1574                node("output", "output"),
1575                node("oops", "error"),
1576                node("up", "upstream"),
1577            ],
1578            edges: vec![edge("input.out", "up.in"), edge("up.success", "output.in")],
1579        }
1580    }
1581
1582    #[test]
1583    fn test_no_black_box_without_default_error_boundary() {
1584        let p = PolicyConfig {
1585            name: "p".into(),
1586            error_handler: None,
1587            nodes: vec![
1588                node("listener", "listener"),
1589                supernode_instance("r", "renamed"),
1590                node("eh", "error-handler"),
1591                node("client", "client"),
1592            ],
1593            edges: vec![
1594                edge("listener.out", "r.in"),
1595                edge("r.success", "client.in"),
1596                edge("r.oops", "eh.in"),
1597                edge("eh.success", "client.in"),
1598            ],
1599        };
1600        let out = expand_policy(&p, &[renamed_error_supernode()]).unwrap();
1601        // No implicit wiring: r/up.error stays unwired (policy catch-all).
1602        assert!(!out.edges.iter().any(|e| e.from == "r/up.error"));
1603    }
1604
1605    /// With the default renamed away, port `error` is unknown — and the message
1606    /// lists the named error port.
1607    #[test]
1608    fn test_error_port_unknown_when_default_renamed() {
1609        let p = PolicyConfig {
1610            name: "p".into(),
1611            error_handler: None,
1612            nodes: vec![
1613                node("listener", "listener"),
1614                supernode_instance("r", "renamed"),
1615                node("client", "client"),
1616            ],
1617            edges: vec![
1618                edge("listener.out", "r.in"),
1619                edge("r.success", "client.in"),
1620                edge("r.error", "client.in"),
1621            ],
1622        };
1623        let err = expand_policy(&p, &[renamed_error_supernode()]).unwrap_err();
1624        assert!(
1625            err.contains("unknown port 'error'") && err.contains("oops"),
1626            "got: {err}"
1627        );
1628    }
1629
1630    /// An unwired named error port just drops its exit edges (optional wiring).
1631    #[test]
1632    fn test_unwired_named_error_port_drops_exit_edges() {
1633        let mut def = renamed_error_supernode();
1634        def.nodes.push(node("auth", "key-auth"));
1635        def.edges = vec![
1636            edge("input.out", "auth.in"),
1637            edge("auth.success", "up.in"),
1638            edge("auth.denied", "up.in"),
1639            edge("auth.error", "oops.in"),
1640            edge("up.success", "output.in"),
1641        ];
1642        let p = PolicyConfig {
1643            name: "p".into(),
1644            error_handler: None,
1645            nodes: vec![
1646                node("listener", "listener"),
1647                supernode_instance("r", "renamed"),
1648                node("client", "client"),
1649            ],
1650            edges: vec![
1651                edge("listener.out", "r.in"),
1652                edge("r.success", "client.in"),
1653                // r.oops deliberately unwired — error-kind ports are optional
1654            ],
1655        };
1656        let out = expand_policy(&p, &[def]).unwrap();
1657        assert!(!out.edges.iter().any(|e| e.from == "r/auth.error"));
1658        assert!(out
1659            .edges
1660            .iter()
1661            .any(|e| e.from == "r/up.success" && e.to == "client.in"));
1662    }
1663}