Skip to content

Commit 0456d49

Browse files
authored
fix(engine): cycle-guard same-law internal references in service layer (#788)
* fix(engine): cycle-guard same-law internal references in service layer The internal-reference branch in resolve_inputs_with_service (source: { output: X } without a regulation) recursively evaluated the referenced article without any cycle detection, unlike every other reference type (external cross-law inputs, hooks, overrides, open terms). A law where two articles source each other's outputs recursed unbounded and aborted the whole process with a stack overflow (exit 134). Guard the branch with the same resolution-context pattern used by the other paths: check is_visited with a distinct "internal:" key (law id + output name, NUL-separated), return EngineError::CircularReference on a cycle, and enter/leave around the recursive evaluation so the guard is released on both success and error paths. Adds service-level regression tests for a direct two-article cycle and an indirect three-article cycle (existing cycle tests only covered ArticleEngine, which has its own visited-set guard). * fix(engine): bound internal-reference recursion depth; add diamond regression test The internal-reference branch was bounded only by the visited set (distinct outputs), so a pathological non-cyclic law with a long chain of internal references could still overflow the stack. Add the same MAX_CROSS_LAW_DEPTH check used by evaluate_law_output_internal, so internal references consume the shared cross-law depth budget. Tests: - diamond regression: two inputs sourcing the SAME output of another article must succeed (no false-positive cycle detection) - deep chain: a linear internal chain longer than MAX_CROSS_LAW_DEPTH returns the depth-exceeded error instead of recursing * fix(engine): emit structured warn when internal-reference depth limit fires
1 parent 0401883 commit 0456d49

1 file changed

Lines changed: 315 additions & 2 deletions

File tree

packages/engine/src/service.rs

Lines changed: 315 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)