Files
zen/core/engine/tests/policy_runtime.rs
stefan-gorules a7157f3448 fix: scope policy evaluation to the import closure (#525)
* fix: scope policy evaluation to the import closure

A policy is now analysed and evaluated together with the policies it
imports (transitively), instead of every policy connected to it by imports
in either direction. Importers and siblings no longer leak into each other:
compiled evaluate() of an imported policy used to run its importers' and
siblings' blocks.

- Evaluation gate analyses the entry's import closure as one unit, so a
  child may read fields and reference entities its importer declares when
  evaluated through that importer
- compile() reports a policy that is only valid through an importer with
  kind "policyImportOnly" instead of failing it as a plain "policy"; errors
  are computed once per key
- Scope diagnostics (duplicate writers, input overrides, data model and
  dictionary collisions, cycles) are reported for the whole closure, and
  conflicts introduced by combining imports are reported on the importer
- Single-entity scope checks run per import closure instead of workspace-wide

* fix: unknown import relationship targets and renames across imports

- An unresolved relationship/reference target inside an imported policy now
  blocks the importer's evaluation and compile(), as on master; the check was
  still limited to the policy being analysed, so the importer compiled and
  the broken child was reported as "policyImportOnly"
- Rename and find-references analyse each policy's expressions in every
  importer's context too, so reads of a field declared by an importer are
  found in the imported policy again
2026-09-28 12:17:12 +02:00

600 lines
19 KiB
Rust

use serde_json::json;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use zen_engine::loader::{LoaderError, MemoryLoader};
use zen_engine::model::{DecisionContent, PolicyContent};
use zen_engine::{
DecisionEngine, EvaluationError, EvaluationOptions, EvaluationSerializedOptions,
EvaluationTraceKind,
};
mod support;
use support::load_test_data;
fn simple_policy_json() -> serde_json::Value {
json!({
"blocks": [
{
"id": "dm-customer",
"type": "dataModel",
"props": {
"data": {
"name": "customer",
"properties": [
{ "id": "p1", "name": "age", "type": "number", "array": false, "optional": false }
]
}
},
"children": []
},
{
"id": "assert1",
"type": "assertion",
"props": {
"data": {
"output": "customer.isAdult",
"conditions": [
{ "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 }
]
}
},
"children": []
}
]
})
}
fn importing_policy_json(import_path: &str, output: &str) -> serde_json::Value {
json!({
"imports": [import_path],
"blocks": [
{
"id": format!("assert-{output}"),
"type": "assertion",
"props": {
"data": {
"output": format!("customer.{output}"),
"conditions": [
{ "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 }
]
}
},
"children": []
}
]
})
}
fn make_policy_content(json: serde_json::Value) -> DecisionContent {
let policy: zen_engine::policy::PolicyDocument =
serde_json::from_value(json).expect("valid policy fixture");
DecisionContent::Policy(PolicyContent(Arc::new(policy)))
}
fn engine_with(loader: Arc<MemoryLoader>) -> DecisionEngine {
DecisionEngine::default().with_loader(loader)
}
#[tokio::test]
async fn evaluate_single_policy() {
let loader = Arc::new(MemoryLoader::default());
loader.add("policy", make_policy_content(simple_policy_json()));
let engine = engine_with(loader);
let result = engine
.evaluate("policy", json!({ "customer": { "age": 30 } }).into())
.await
.expect("evaluate ok");
let result_json: serde_json::Value = result.result.into();
assert_eq!(
result_json.pointer("/customer/isAdult"),
Some(&json!(true)),
"expected customer.isAdult=true, got {result_json:#?}",
);
}
#[tokio::test]
async fn evaluate_with_single_import() {
let loader = Arc::new(MemoryLoader::default());
loader.add("base", make_policy_content(simple_policy_json()));
loader.add(
"entry",
make_policy_content(importing_policy_json("base", "fromEntry")),
);
let engine = engine_with(loader);
let result = engine
.evaluate("entry", json!({ "customer": { "age": 70 } }).into())
.await
.expect("evaluate ok");
let result_json: serde_json::Value = result.result.into();
assert_eq!(result_json.pointer("/customer/isAdult"), Some(&json!(true)));
assert_eq!(
result_json.pointer("/customer/fromEntry"),
Some(&json!(true))
);
}
#[tokio::test]
async fn evaluate_chained_imports() {
let loader = Arc::new(MemoryLoader::default());
loader.add("base", make_policy_content(simple_policy_json()));
loader.add(
"middle",
make_policy_content(importing_policy_json("base", "fromMiddle")),
);
loader.add(
"entry",
make_policy_content(importing_policy_json("middle", "fromEntry")),
);
let engine = engine_with(loader);
let result = engine
.evaluate("entry", json!({ "customer": { "age": 80 } }).into())
.await
.expect("evaluate ok");
let result_json: serde_json::Value = result.result.into();
assert_eq!(result_json.pointer("/customer/isAdult"), Some(&json!(true)));
assert_eq!(
result_json.pointer("/customer/fromMiddle"),
Some(&json!(true))
);
assert_eq!(
result_json.pointer("/customer/fromEntry"),
Some(&json!(true))
);
}
#[tokio::test]
async fn cycle_terminates() {
let loader = Arc::new(MemoryLoader::default());
loader.add("a", make_policy_content(importing_policy_json("b", "aOut")));
loader.add("b", make_policy_content(importing_policy_json("a", "bOut")));
let engine = engine_with(loader);
let result = engine
.evaluate("a", json!({ "customer": { "age": 25 } }).into())
.await;
let _ = result;
}
#[tokio::test]
async fn self_import_terminates() {
let loader = Arc::new(MemoryLoader::default());
loader.add(
"a",
make_policy_content(importing_policy_json("a", "selfOut")),
);
let engine = engine_with(loader);
let result = engine
.evaluate("a", json!({ "customer": { "age": 25 } }).into())
.await;
let _ = result;
}
#[tokio::test]
async fn policy_importing_graph_fails() {
let loader = Arc::new(MemoryLoader::default());
loader.add(
"entry",
make_policy_content(importing_policy_json("g", "fromEntry")),
);
loader.add("g", load_test_data("table.json"));
let engine = engine_with(loader);
let err = engine
.evaluate("entry", json!({ "customer": { "age": 25 } }).into())
.await
.expect_err("policy importing a graph should fail");
match *err {
EvaluationError::ContentKindMismatch { expected, got, .. } => {
assert_eq!(expected, "policy");
assert_eq!(got, "graph");
}
other => panic!("expected ContentKindMismatch, got: {other:?}"),
}
}
#[tokio::test]
async fn missing_import_is_loader_error() {
let loader = Arc::new(MemoryLoader::default());
loader.add(
"entry",
make_policy_content(importing_policy_json("does-not-exist", "fromEntry")),
);
let engine = engine_with(loader);
let err = engine
.evaluate("entry", json!({ "customer": { "age": 25 } }).into())
.await
.expect_err("missing import should surface loader error");
assert!(
matches!(*err, EvaluationError::LoaderError(_)),
"expected LoaderError, got: {err:?}"
);
}
fn assert_string_trace(value: serde_json::Value) {
let trace = value.pointer("/trace").expect("trace key present");
let trace_str = trace.as_str().expect("trace must serialize as a string");
let parsed: serde_json::Value = serde_json::from_str(trace_str).expect("trace string is JSON");
assert!(parsed.pointer("/properties").is_some());
assert!(parsed.pointer("/executions").is_some());
}
#[tokio::test]
async fn serialized_string_trace_for_policies() {
let loader = Arc::new(MemoryLoader::default());
loader.add("policy", make_policy_content(simple_policy_json()));
let engine = engine_with(loader);
let options = EvaluationSerializedOptions {
trace: EvaluationTraceKind::String,
..Default::default()
};
let lazy = engine
.evaluate_serialized(
"policy",
json!({ "customer": { "age": 30 } }).into(),
options,
)
.await
.expect("evaluate ok");
assert_string_trace(lazy);
engine.compile();
let precompiled = engine
.evaluate_serialized(
"policy",
json!({ "customer": { "age": 30 } }).into(),
options,
)
.await
.expect("evaluate ok");
assert_string_trace(precompiled);
}
#[tokio::test]
async fn diamond_imports_load_each_key_once() {
let mut contents: HashMap<String, Arc<DecisionContent>> = HashMap::new();
contents.insert(
"entry".into(),
Arc::new(make_policy_content(json!({
"imports": ["a", "b"],
"blocks": [
{
"id": "assert-entry",
"type": "assertion",
"props": {
"data": {
"output": "customer.fromEntry",
"conditions": [
{ "id": "c1", "expression": "customer.age >= 18", "operator": "and", "depth": 0 }
]
}
}
}
]
}))),
);
contents.insert(
"a".into(),
Arc::new(make_policy_content(importing_policy_json("base", "fromA"))),
);
contents.insert(
"b".into(),
Arc::new(make_policy_content(importing_policy_json("base", "fromB"))),
);
contents.insert(
"base".into(),
Arc::new(make_policy_content(simple_policy_json())),
);
let counts: Arc<Mutex<HashMap<String, usize>>> = Arc::new(Mutex::new(HashMap::new()));
let counts_in_loader = counts.clone();
let engine = DecisionEngine::default().with_closure_loader(move |key| {
let counts = counts_in_loader.clone();
let content = contents.get(&key).cloned();
async move {
*counts.lock().unwrap().entry(key.clone()).or_insert(0) += 1;
content.ok_or_else(|| LoaderError::NotFound(key))
}
});
engine
.evaluate("entry", json!({ "customer": { "age": 30 } }).into())
.await
.expect("evaluate ok");
let counts = counts.lock().unwrap();
for key in ["entry", "a", "b", "base"] {
assert_eq!(
counts.get(key),
Some(&1),
"key {key} should load exactly once, got {counts:?}"
);
}
}
#[tokio::test]
async fn trace_serializes_for_policies() {
let loader = Arc::new(MemoryLoader::default());
loader.add("policy", make_policy_content(simple_policy_json()));
let engine = engine_with(loader);
let result = engine
.evaluate_with_opts(
"policy",
json!({ "customer": { "age": 30 } }).into(),
EvaluationOptions {
trace: true,
..Default::default()
},
)
.await
.expect("evaluate ok");
assert!(result.trace.is_some(), "trace requested but None returned");
let serialized = serde_json::to_value(&result).expect("serialize ok");
let trace = serialized.pointer("/trace").expect("trace key present");
assert!(
trace.pointer("/properties").is_some(),
"policy trace should serialize with `properties` field"
);
assert!(
trace.pointer("/executions").is_some(),
"policy trace should serialize with `executions` field"
);
}
fn parent_declares_child_reads(child_condition: &str) -> Arc<MemoryLoader> {
let loader = Arc::new(MemoryLoader::default());
loader.add(
"main",
make_policy_content(json!({
"imports": ["shared"],
"blocks": [
{ "id": "dm", "type": "dataModel", "props": { "data": {
"name": "customer",
"properties": [
{ "id": "p1", "name": "name", "type": "string", "array": false, "optional": false },
{ "id": "p2", "name": "age", "type": "number", "array": false, "optional": false }
]
} }, "children": [] },
{ "id": "s1", "type": "expression", "props": { "data": {
"key": "customer.discount", "value": "customer.tier"
} } }
]
})),
);
loader.add(
"shared",
make_policy_content(json!({
"blocks": [
{ "id": "m", "type": "match", "props": { "data": {
"key": "customer.tier",
"arms": [
{ "id": "a1", "condition": child_condition, "value": "\"gold\"" },
{ "id": "a2", "condition": "", "value": "\"silver\"" }
]
} } }
]
})),
);
loader
}
#[tokio::test]
async fn evaluate_child_reading_parent_declared_fields() {
let engine = engine_with(parent_declares_child_reads("customer.age > 50"));
let result = engine
.evaluate(
"main",
json!({ "customer": { "name": "Ann", "age": 60 } }).into(),
)
.await
.expect("evaluate ok");
let result_json: serde_json::Value = result.result.into();
assert_eq!(result_json.pointer("/customer/tier"), Some(&json!("gold")));
assert_eq!(
result_json.pointer("/customer/discount"),
Some(&json!("gold"))
);
}
#[tokio::test]
async fn evaluate_child_with_real_error_is_refused() {
let engine = engine_with(parent_declares_child_reads("customer.missing > 50"));
let result = engine
.evaluate(
"main",
json!({ "customer": { "name": "Ann", "age": 60 } }).into(),
)
.await;
assert!(
format!("{result:?}").contains("CompilationErrors"),
"expected compilation error, got {result:?}"
);
}
#[tokio::test]
async fn evaluate_refuses_child_block_mixed_under_parent_data_model() {
let loader = Arc::new(MemoryLoader::default());
let data_model = |id: &str, name: &str| {
json!({ "id": id, "type": "dataModel", "props": { "data": {
"name": name,
"properties": [
{ "id": format!("{id}-p"), "name": "name", "type": "string", "array": false, "optional": false }
]
} }, "children": [] })
};
loader.add(
"main",
make_policy_content(json!({
"imports": ["child"],
"blocks": [data_model("dm-customer", "customer"), data_model("dm-company", "company")]
})),
);
loader.add(
"child",
make_policy_content(json!({
"blocks": [
{ "id": "bad-block", "type": "decisionTable", "props": { "data": {
"hitPolicy": "first",
"inputs": [],
"outputs": [
{ "id": "s1", "name": "", "field": "customer.label" },
{ "id": "s2", "name": "", "field": "company.label" }
],
"rules": [{ "_id": "r1", "s1": "\"x\"", "s2": "\"y\"" }]
} } }
]
})),
);
let engine = engine_with(loader);
let result = engine
.evaluate(
"main",
json!({ "customer": { "name": "a" }, "company": { "name": "b" } }).into(),
)
.await;
assert!(
format!("{result:?}").contains("CompilationErrors"),
"expected compilation error, got {result:?}"
);
}
#[tokio::test]
async fn evaluate_refuses_child_unreachable_read_under_parent_data_model() {
let loader = Arc::new(MemoryLoader::default());
loader.add(
"main",
make_policy_content(json!({
"imports": ["child"],
"blocks": [
{ "id": "dm-customer", "type": "dataModel", "props": { "data": {
"name": "customer",
"properties": [
{ "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false }
]
} } },
{ "id": "dm-company", "type": "dataModel", "props": { "data": {
"name": "company",
"properties": [
{ "id": "p2", "name": "revenue", "type": "number", "array": false, "optional": false }
]
} } }
]
})),
);
loader.add(
"child",
make_policy_content(json!({
"blocks": [
{ "id": "assert", "type": "assertion", "props": { "data": {
"output": "globalFlag",
"conditions": [
{ "id": "c1", "expression": "company.revenue > 0", "operator": "and", "depth": 0 }
]
} } }
]
})),
);
let engine = engine_with(loader);
let result = engine
.evaluate(
"main",
json!({ "customer": { "companies": [{ "revenue": 5 }] } }).into(),
)
.await;
assert!(
format!("{result:?}").contains("CompilationErrors"),
"expected compilation error, got {result:?}"
);
}
#[tokio::test]
async fn compile_accepts_child_reading_parent_declared_fields() {
let engine = engine_with(parent_declares_child_reads("customer.age > 50"));
let failures = engine.compile();
let reported: Vec<(&str, &str)> = failures.iter().map(|f| (f.key.as_ref(), f.kind)).collect();
assert_eq!(
reported,
vec![("shared", "policyImportOnly")],
"{failures:#?}"
);
let result = engine
.evaluate(
"main",
json!({ "customer": { "name": "Ann", "age": 60 } }).into(),
)
.await
.expect("evaluate ok");
let result_json: serde_json::Value = result.result.into();
assert_eq!(result_json.pointer("/customer/tier"), Some(&json!("gold")));
}
#[tokio::test]
async fn compile_reports_child_with_real_error() {
let engine = engine_with(parent_declares_child_reads("customer.missing > 50"));
let failures = engine.compile();
let keys: Vec<&str> = failures.iter().map(|f| f.key.as_ref()).collect();
assert!(keys.contains(&"main"), "{failures:#?}");
}
#[tokio::test]
async fn unknown_relationship_target_in_import_refuses_importer() {
let loader = Arc::new(MemoryLoader::default());
loader.add(
"main",
make_policy_content(json!({
"imports": ["child"],
"blocks": [
{ "id": "s1", "type": "expression", "props": { "data": {
"key": "flag", "value": "true"
} } }
]
})),
);
loader.add(
"child",
make_policy_content(json!({
"blocks": [
{ "id": "dm", "type": "dataModel", "props": { "data": {
"name": "customer",
"properties": [
{ "id": "p1", "name": "companies", "type": "relationship", "target": "company", "array": true, "optional": false }
]
} }, "children": [] }
]
})),
);
let engine = engine_with(loader);
let result = engine.evaluate("main", json!({}).into()).await;
assert!(
format!("{result:?}").contains("CompilationErrors"),
"expected compilation error, got {result:?}"
);
let failures = engine.compile();
let reported: Vec<(&str, &str)> = failures.iter().map(|f| (f.key.as_ref(), f.kind)).collect();
assert!(reported.contains(&("main", "policy")), "{failures:#?}");
assert!(reported.contains(&("child", "policy")), "{failures:#?}");
}