@@ -2045,6 +2045,21 @@ impl LawExecutionService {
20452045 res_ctx
20462046 . trace_set_message ( format ! ( "Internal reference: {}#{}" , law. id, output_name) ) ;
20472047
2048+ // Cycle detection: check if this internal output is already being
2049+ // resolved. Use \0 as separator to prevent key collisions, and an
2050+ // "internal:" prefix to keep keys distinct from external references.
2051+ let internal_key = format ! ( "internal:{}\0 {}" , law. id, output_name) ;
2052+ if res_ctx. is_visited ( & internal_key) {
2053+ res_ctx. trace_set_message ( format ! (
2054+ "Circular internal reference detected: {}#{} is already being resolved" ,
2055+ law. id, output_name
2056+ ) ) ;
2057+ return Err ( EngineError :: CircularReference ( format ! (
2058+ "Circular internal reference: output '{}' in {} is already being resolved" ,
2059+ output_name, law. id
2060+ ) ) ) ;
2061+ }
2062+
20482063 let ref_article = match law. find_article_by_output ( output_name) {
20492064 Some ( a) => a,
20502065 None => {
@@ -2060,14 +2075,49 @@ impl LawExecutionService {
20602075 } ;
20612076
20622077 let ref_params = parameters. clone ( ) ;
2063- let result = match self . evaluate_article_with_service (
2078+
2079+ // Enter internal resolution scope for cycle detection
2080+ res_ctx. enter ( internal_key. clone ( ) ) ;
2081+
2082+ // Check depth limit (mirrors evaluate_law_output_internal): the
2083+ // visited set bounds distinct outputs, but a long non-cyclic
2084+ // internal chain could still overflow the stack.
2085+ if res_ctx. depth > config:: MAX_CROSS_LAW_DEPTH {
2086+ res_ctx. leave ( & internal_key) ;
2087+ tracing:: warn!(
2088+ law_id = %law. id,
2089+ output = %output_name,
2090+ depth = res_ctx. depth,
2091+ "Cross-law resolution depth exceeded (internal reference)"
2092+ ) ;
2093+ res_ctx. trace_set_message ( format ! (
2094+ "Cross-law resolution depth exceeded {} levels ({}:{})" ,
2095+ config:: MAX_CROSS_LAW_DEPTH ,
2096+ law. id,
2097+ output_name
2098+ ) ) ;
2099+ return Err ( EngineError :: CircularReference ( format ! (
2100+ "Cross-law resolution depth exceeded {} levels. \
2101+ Possible circular reference involving {}:{}",
2102+ config:: MAX_CROSS_LAW_DEPTH ,
2103+ law. id,
2104+ output_name
2105+ ) ) ) ;
2106+ }
2107+
2108+ let eval_result = self . evaluate_article_with_service (
20642109 ref_article,
20652110 law,
20662111 ref_params,
20672112 Some ( output_name) ,
20682113 "BESLUIT" ,
20692114 res_ctx,
2070- ) {
2115+ ) ;
2116+
2117+ // Leave scope (even on error, for correct cycle tracking)
2118+ res_ctx. leave ( & internal_key) ;
2119+
2120+ let result = match eval_result {
20712121 Ok ( r) => r,
20722122 Err ( e) => {
20732123 res_ctx. trace_set_message ( format ! ( "Internal reference failed: {}" , e) ) ;
@@ -2617,6 +2667,269 @@ articles:
26172667 ) ;
26182668 }
26192669
2670+ #[ test]
2671+ fn test_service_internal_circular_reference ( ) {
2672+ // Same-law cycle: article 1's input sources article 2's output,
2673+ // and article 2's input sources article 1's output.
2674+ // Previously this recursed unbounded and crashed with a stack overflow.
2675+ let law = r#"
2676+ $id: internal_cycle_law
2677+ regulatory_layer: WET
2678+ publication_date: '2025-01-01'
2679+ articles:
2680+ - number: '1'
2681+ text: References output of article 2
2682+ machine_readable:
2683+ execution:
2684+ input:
2685+ - name: from_b
2686+ type: number
2687+ source:
2688+ output: output_b
2689+ output:
2690+ - name: output_a
2691+ type: number
2692+ actions:
2693+ - output: output_a
2694+ value: $from_b
2695+ - number: '2'
2696+ text: References output of article 1
2697+ machine_readable:
2698+ execution:
2699+ input:
2700+ - name: from_a
2701+ type: number
2702+ source:
2703+ output: output_a
2704+ output:
2705+ - name: output_b
2706+ type: number
2707+ actions:
2708+ - output: output_b
2709+ value: $from_a
2710+ "# ;
2711+
2712+ let mut service = LawExecutionService :: new ( ) ;
2713+ service. load_law ( law) . unwrap ( ) ;
2714+
2715+ let result = service. evaluate_law_output (
2716+ "internal_cycle_law" ,
2717+ "output_a" ,
2718+ BTreeMap :: new ( ) ,
2719+ "2025-01-01" ,
2720+ ) ;
2721+
2722+ assert ! (
2723+ matches!( result, Err ( EngineError :: CircularReference ( _) ) ) ,
2724+ "Expected CircularReference error, got: {:?}" ,
2725+ result
2726+ ) ;
2727+ }
2728+
2729+ #[ test]
2730+ fn test_service_internal_circular_reference_indirect ( ) {
2731+ // Indirect same-law cycle across three articles:
2732+ // article 1 -> article 2 -> article 3 -> article 1.
2733+ let law = r#"
2734+ $id: internal_cycle_law_3
2735+ regulatory_layer: WET
2736+ publication_date: '2025-01-01'
2737+ articles:
2738+ - number: '1'
2739+ text: References output of article 2
2740+ machine_readable:
2741+ execution:
2742+ input:
2743+ - name: from_b
2744+ type: number
2745+ source:
2746+ output: output_b
2747+ output:
2748+ - name: output_a
2749+ type: number
2750+ actions:
2751+ - output: output_a
2752+ value: $from_b
2753+ - number: '2'
2754+ text: References output of article 3
2755+ machine_readable:
2756+ execution:
2757+ input:
2758+ - name: from_c
2759+ type: number
2760+ source:
2761+ output: output_c
2762+ output:
2763+ - name: output_b
2764+ type: number
2765+ actions:
2766+ - output: output_b
2767+ value: $from_c
2768+ - number: '3'
2769+ text: References output of article 1
2770+ machine_readable:
2771+ execution:
2772+ input:
2773+ - name: from_a
2774+ type: number
2775+ source:
2776+ output: output_a
2777+ output:
2778+ - name: output_c
2779+ type: number
2780+ actions:
2781+ - output: output_c
2782+ value: $from_a
2783+ "# ;
2784+
2785+ let mut service = LawExecutionService :: new ( ) ;
2786+ service. load_law ( law) . unwrap ( ) ;
2787+
2788+ let result = service. evaluate_law_output (
2789+ "internal_cycle_law_3" ,
2790+ "output_a" ,
2791+ BTreeMap :: new ( ) ,
2792+ "2025-01-01" ,
2793+ ) ;
2794+
2795+ assert ! (
2796+ matches!( result, Err ( EngineError :: CircularReference ( _) ) ) ,
2797+ "Expected CircularReference error, got: {:?}" ,
2798+ result
2799+ ) ;
2800+ }
2801+
2802+ #[ test]
2803+ fn test_service_internal_diamond_reference_succeeds ( ) {
2804+ // Legitimate diamond, no cycle: two inputs of article 1 both source
2805+ // the SAME output of article 2. The cycle guard must not produce a
2806+ // false positive here (the visited key is released between siblings).
2807+ let law = r#"
2808+ $id: internal_diamond_law
2809+ regulatory_layer: WET
2810+ publication_date: '2025-01-01'
2811+ articles:
2812+ - number: '1'
2813+ text: Sums the same output of article 2 twice
2814+ machine_readable:
2815+ execution:
2816+ input:
2817+ - name: left
2818+ type: number
2819+ source:
2820+ output: shared_value
2821+ - name: right
2822+ type: number
2823+ source:
2824+ output: shared_value
2825+ output:
2826+ - name: combined
2827+ type: number
2828+ actions:
2829+ - output: combined
2830+ operation: ADD
2831+ values:
2832+ - $left
2833+ - $right
2834+ - number: '2'
2835+ text: Provides a shared value
2836+ machine_readable:
2837+ execution:
2838+ output:
2839+ - name: shared_value
2840+ type: number
2841+ actions:
2842+ - output: shared_value
2843+ value: 21
2844+ "# ;
2845+
2846+ let mut service = LawExecutionService :: new ( ) ;
2847+ service. load_law ( law) . unwrap ( ) ;
2848+
2849+ let result = service
2850+ . evaluate_law_output (
2851+ "internal_diamond_law" ,
2852+ "combined" ,
2853+ BTreeMap :: new ( ) ,
2854+ "2025-01-01" ,
2855+ )
2856+ . expect ( "diamond-shaped internal references must not be flagged as circular" ) ;
2857+
2858+ assert_eq ! ( result. outputs. get( "combined" ) , Some ( & Value :: Int ( 42 ) ) ) ;
2859+ }
2860+
2861+ #[ test]
2862+ fn test_service_internal_deep_chain_depth_limit ( ) {
2863+ // A linear (non-cyclic) chain of internal references longer than
2864+ // MAX_CROSS_LAW_DEPTH must hit the depth limit instead of recursing
2865+ // until the stack overflows.
2866+ let chain_len = config:: MAX_CROSS_LAW_DEPTH + 5 ;
2867+ let mut yaml = String :: from (
2868+ "$id: internal_deep_chain_law\n \
2869+ regulatory_layer: WET\n \
2870+ publication_date: '2025-01-01'\n \
2871+ articles:\n ",
2872+ ) ;
2873+ for i in 0 ..chain_len {
2874+ yaml. push_str ( & format ! (
2875+ r#" - number: '{num}'
2876+ text: Chain link {num}
2877+ machine_readable:
2878+ execution:
2879+ input:
2880+ - name: from_next
2881+ type: number
2882+ source:
2883+ output: value_{next}
2884+ output:
2885+ - name: value_{num}
2886+ type: number
2887+ actions:
2888+ - output: value_{num}
2889+ value: $from_next
2890+ "# ,
2891+ num = i,
2892+ next = i + 1
2893+ ) ) ;
2894+ }
2895+ // Terminal article: a literal value, no further references.
2896+ yaml. push_str ( & format ! (
2897+ r#" - number: '{num}'
2898+ text: Chain terminal
2899+ machine_readable:
2900+ execution:
2901+ output:
2902+ - name: value_{num}
2903+ type: number
2904+ actions:
2905+ - output: value_{num}
2906+ value: 1
2907+ "# ,
2908+ num = chain_len
2909+ ) ) ;
2910+
2911+ let mut service = LawExecutionService :: new ( ) ;
2912+ service. load_law ( & yaml) . unwrap ( ) ;
2913+
2914+ let result = service. evaluate_law_output (
2915+ "internal_deep_chain_law" ,
2916+ "value_0" ,
2917+ BTreeMap :: new ( ) ,
2918+ "2025-01-01" ,
2919+ ) ;
2920+
2921+ match result {
2922+ Err ( EngineError :: CircularReference ( msg) ) => {
2923+ assert ! (
2924+ msg. contains( "depth exceeded" ) ,
2925+ "Expected depth-exceeded error, got: {}" ,
2926+ msg
2927+ ) ;
2928+ }
2929+ other => panic ! ( "Expected CircularReference depth error, got: {:?}" , other) ,
2930+ }
2931+ }
2932+
26202933 // -------------------------------------------------------------------------
26212934 // Parameter Override Tests
26222935 // -------------------------------------------------------------------------
0 commit comments