Skip to main content

evorule_discipline/
lib.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 EvoRule Project
3// This file is part of EvoRule, licensed under GNU Affero General Public License v3 or later.
4//! 规则集形态门禁(L2 纪律)—— 判定引擎(机制侧)
5//!
6//! # 机制 / 策略分离
7//!
8//! - **策略(可变)**:`evorule-tcb/discipline/core_eval.json` —— 9 条 DC 纪律,
9//!   每条是带 `reason` 的 `enforce` 规则。改纪律 = 改 JSON,不动 Rust。
10//! - **机制(不可变)**:本 crate —— 遍历规则集、把内核看不见的「列表级事实」
11//!   标注进 `instruction._ctx`,然后交给 `execute_transition` 求值。
12//!
13//! `core_eval` 没有循环原语(元指令全集仅 branch/set/push/io_request/enforce),
14//! 因此遍历必须由外层驱动;**判定全部在 JSON**,这是纪律可版本化、可评审的前提。
15//!
16//! # 为什么是前置而非运行时
17//!
18//! 运行时做会污染 `rule_hits` 归因口径、挤占 64 规则 / 1024 指令预算;且规则集
19//! 装载后不再变化,每次转换都校验纯属浪费。更关键的是语义隔离:装载期校验失败
20//! 的表现是「拒载,规则集没加载起来」,运行时校验失败的表现是业务 `Violation`
21//! 事实入链——**两者在审计链上长得一模一样**,规则写得丑会和真的违宪混在一起。
22//!
23//! # 使用方(B 段接线,design-06 §11)
24//!
25//! - `evorule validate`(evorule-cli,薄壳命令)——开发者/CI 前置;
26//! - `evorule-server` 装载期门禁(`parse_rule_file` + `load_core_eval_transforms`
27//!   两个接线点)——拦截 LLM 补丁热重载 / bundle 导入 / 宪法装载的威胁路径。
28//!
29//! # 边界
30//!
31//! 这是**形态**门禁,不是语义证明。它拦得住「结构会让守卫失效」,拦不住
32//! 「规则写错了业务约束」;也不证明 `execute_transition` 自身正确(那是 TCB_SPEC
33//! 的 T/G/D 系列与 Kani 的职责)。用 TCB 检查喂给 TCB 的规则属于自证
34//! (见 evorule-tcb/src/discipline.rs「诚实边界」),与落点无关。
35
36use std::collections::BTreeSet;
37
38use evorule_tcb::discipline::DISCIPLINE_CORE_EVAL;
39use evorule_tcb::{execute_transition, JsonValue, TransitionResult};
40
41/// 门禁求值失败(纪律数据损坏 / 内核求值异常)。非违规——违规是 `Report`。
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct DisciplineError(pub String);
44
45impl std::fmt::Display for DisciplineError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(f, "discipline gate error: {}", self.0)
48    }
49}
50
51impl std::error::Error for DisciplineError {}
52
53/// 一条纪律违规
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Violation {
56    /// 节点在被校验规则集中的位置,如 `[0]` / `[0].on_true[1]`
57    pub path: String,
58    /// 节点的 `type` 字段(缺失时为空串)
59    pub node_type: String,
60    /// 纪律条款号,如 `DC-05`
61    pub code: String,
62    /// 违规说明(取自纪律条款的 `params.reason`,可直接进审计报告)
63    pub reason: String,
64}
65
66/// 加载嵌入的纪律规则集
67///
68/// 复用 `evorule-reactor::serde_to_tcb` 做序列化,与 WAL / auditor 走同一条
69/// 转换路径,避免「门禁看到的 JSON 与内核看到的 JSON 不一致」。
70fn load_discipline() -> Result<Vec<JsonValue>, DisciplineError> {
71    let raw: serde_json::Value = serde_json::from_str(DISCIPLINE_CORE_EVAL).map_err(|e| {
72        DisciplineError(format!(
73            "embedded discipline core_eval is not valid JSON: {e}"
74        ))
75    })?;
76    let arr = raw.as_array().ok_or_else(|| {
77        DisciplineError("embedded discipline core_eval must be a JSON array of rules".to_string())
78    })?;
79    Ok(arr.iter().map(evorule_reactor::serde_to_tcb).collect())
80}
81
82/// 纪律条款数量(供报告头展示)
83pub fn discipline_len() -> Result<usize, DisciplineError> {
84    Ok(load_discipline()?.len())
85}
86
87/// 门禁报告
88#[derive(Debug, Clone, PartialEq, Eq, Default)]
89pub struct Report {
90    /// 被检查的节点总数(含 branch 内的嵌套子指令)
91    pub scanned: usize,
92    /// 违规列表(空 = 规则集合规,通过门禁)
93    pub violations: Vec<Violation>,
94}
95
96/// 对被校验规则集执行全部纪律条款
97pub fn check(ruleset: &[JsonValue]) -> Result<Report, DisciplineError> {
98    let discipline = load_discipline()?;
99    let tops: Vec<TopFacts> = ruleset.iter().map(build_top_facts).collect();
100    let nodes = flatten(ruleset, &tops);
101
102    let mut violations = Vec::new();
103    for nd in &nodes {
104        let probe = build_probe(nd, &tops);
105        // 纪律条款在数组中的下标 + 1 == DC 编号(见 core_eval.json 的顺序约定)
106        match execute_transition(&discipline, &probe, &JsonValue::Null, &[]) {
107            Ok(TransitionResult::Halted { rule_index, reason }) => violations.push(Violation {
108                path: nd.path.clone(),
109                node_type: node_type(nd.node).to_string(),
110                code: {
111                    let n = rule_index.saturating_add(1);
112                    format!("DC-{n:02}")
113                },
114                reason,
115            }),
116            Ok(_) => {}
117            Err(e) => {
118                return Err(DisciplineError(format!(
119                    "discipline evaluation failed at {}: {e:?}",
120                    nd.path
121                )))
122            }
123        }
124    }
125    Ok(Report {
126        scanned: nodes.len(),
127        violations,
128    })
129}
130
131// ---------------------------------------------------------------- 遍历与结构事实
132
133fn node_type(n: &JsonValue) -> &str {
134    n.get("type").and_then(JsonValue::as_str).unwrap_or("")
135}
136
137/// `params` 是否含有某个 key —— **字段存在性**,与值是否为 null 无关
138///
139/// 为什么需要它:domain 的 `exists` 谓词语义是「null 视为已清除」,
140/// 无法表达「有这个字段,只是值是 null」。而 `set value: null`(清空 I/O
141/// 结果)正是受支持的合法写法(见 domain.rs 模块文档),故 DC-06 必须按
142/// 字段存在性而非值非空判定。
143fn params_has_key(n: &JsonValue, key: &str) -> bool {
144    n.get("params")
145        .and_then(JsonValue::as_object)
146        .map(|o| o.contains_key(key))
147        .unwrap_or(false)
148}
149
150fn children_of<'a>(n: &'a JsonValue, key: &str) -> Vec<&'a JsonValue> {
151    match n
152        .get("params")
153        .and_then(|p| p.get(key))
154        .and_then(JsonValue::as_array)
155    {
156        Some(a) => a.iter().collect(),
157        None => Vec::new(),
158    }
159}
160
161/// 从 domain 递归收集 `instruction_type` 目标集合;空集 = 不设限(ANY)
162fn collect_targets(domain: &JsonValue, out: &mut BTreeSet<String>) {
163    match domain.get("type").and_then(JsonValue::as_str) {
164        Some("instruction") => {
165            if let Some(s) = domain.get("instruction_type").and_then(JsonValue::as_str) {
166                out.insert(s.to_string());
167            }
168        }
169        Some("all") => {
170            if let Some(JsonValue::Array(items)) = domain.get("inner") {
171                for d in items {
172                    collect_targets(d, out);
173                }
174            }
175        }
176        Some("not") => {
177            if let Some(inner) = domain.get("inner") {
178                collect_targets(inner, out);
179            }
180        }
181        _ => {}
182    }
183}
184
185fn top_targets(rule: &JsonValue) -> BTreeSet<String> {
186    let mut s = BTreeSet::new();
187    if let Some(d) = rule.get("params").and_then(|p| p.get("domain")) {
188        collect_targets(d, &mut s);
189    }
190    s
191}
192
193/// 从 domain 收集**可证明的合取式** `instruction_type` 目标集合
194///
195/// 与 [`collect_targets`] 的区别:**不下钻 `not`**。`not(instruction X)` 的语义是
196/// 「类型不是 X」,把 X 收进目标集合对遮蔽判定(交集=可能同命中)是保守的,
197/// 但对「同趟可达」判定(交集=可能同时执行)方向相反——下钻 `not` 会把
198/// 反目标当正目标,推出不健全的「不互斥/不可达」。此处宁可空集(= ANY)。
199fn provable_targets(rule: &JsonValue) -> BTreeSet<String> {
200    fn walk(domain: &JsonValue, out: &mut BTreeSet<String>) {
201        match domain.get("type").and_then(JsonValue::as_str) {
202            Some("instruction") => {
203                if let Some(s) = domain.get("instruction_type").and_then(JsonValue::as_str) {
204                    out.insert(s.to_string());
205                }
206            }
207            // 仅合取语境(顶层单谓词 / all)可证明「命中 ⇒ 类型 ∈ 集合」
208            Some("all") => {
209                if let Some(JsonValue::Array(items)) = domain.get("inner") {
210                    for d in items {
211                        walk(d, out);
212                    }
213                }
214            }
215            _ => {}
216        }
217    }
218    let mut s = BTreeSet::<String>::new();
219    if let Some(d) = rule.get("params").and_then(|p| p.get("domain")) {
220        walk(d, &mut s);
221    }
222    s
223}
224
225/// 早退规则:自身或其子指令会产生 IoRequired / Halted 信号(传播即停)
226fn is_early_exit(n: &JsonValue) -> bool {
227    match node_type(n) {
228        "enforce" | "io_request" => true,
229        "branch" => ["on_true", "on_false"]
230            .iter()
231            .any(|k| children_of(n, k).iter().any(|c| is_early_exit(c))),
232        _ => false,
233    }
234}
235
236/// 子树中 io_request 的个数(D4:一条规则最多一个 I/O)
237fn count_io(n: &JsonValue) -> usize {
238    let mut c = usize::from(node_type(n) == "io_request");
239    if node_type(n) == "branch" {
240        for key in ["on_true", "on_false"] {
241            for ch in children_of(n, key) {
242                c += count_io(ch);
243            }
244        }
245    }
246    c
247}
248
249/// 子树是否含改状态操作(set / push)—— D5 判据
250fn has_mutating(n: &JsonValue) -> bool {
251    if matches!(node_type(n), "set" | "push") {
252        return true;
253    }
254    node_type(n) == "branch"
255        && ["on_true", "on_false"]
256            .iter()
257            .any(|k| children_of(n, k).iter().any(|c| has_mutating(c)))
258}
259
260/// 目标集合是否可能相交(空集视为 ANY → 保守判 true)
261fn maybe_overlap(a: &BTreeSet<String>, b: &BTreeSet<String>) -> bool {
262    a.is_empty() || b.is_empty() || a.intersection(b).next().is_some()
263}
264
265/// 一条顶层规则的结构事实(供遮蔽判定与同趟可达判定共用)
266struct TopFacts {
267    /// 早退规则:自身或其子指令会产生信号(传播即停)
268    shadow_source: bool,
269    /// domain 的 instruction_type 目标集合(空集 = ANY;含 not 下降,DC-05 语义)
270    targets: BTreeSet<String>,
271    /// 可证明的合取式指令类型目标集(不下钻 not;同趟判定专用)
272    provable: BTreeSet<String>,
273    /// domain 中**正**存在断言的路径(`exists P`)
274    pos_exists: BTreeSet<String>,
275    /// domain 中**负**存在断言的路径(`not(exists P)`)
276    neg_exists: BTreeSet<String>,
277}
278
279/// 构造单条顶层规则的结构事实
280fn build_top_facts(r: &JsonValue) -> TopFacts {
281    let mut pos = BTreeSet::new();
282    let mut neg = BTreeSet::new();
283    if let Some(d) = r.get("params").and_then(|p| p.get("domain")) {
284        collect_exists_paths(d, false, &mut pos, &mut neg);
285    }
286    TopFacts {
287        shadow_source: is_shadow_source(r),
288        targets: top_targets(r),
289        provable: provable_targets(r),
290        pos_exists: pos,
291        neg_exists: neg,
292    }
293}
294
295/// 收集 domain 中的正/负 exists 路径
296///
297/// **只在可证明的语境下收集**(report-006 F1 / R-A4 精度修复):
298/// - 顶层与 `all` 是合取:`exists P` ⇒ 正断言;
299/// - `not(exists P)` ⇒ 负断言(仅下钻一层取反);
300/// - 其余嵌套(`not(all)`、`not(not)` 等)**不下钻**——`not(A ∧ B)` 推不出
301///   `¬A`,冒然收集会引入不健全的"互斥",宁可保持保守。
302fn collect_exists_paths(
303    domain: &JsonValue,
304    negated: bool,
305    pos: &mut BTreeSet<String>,
306    neg: &mut BTreeSet<String>,
307) {
308    match domain.get("type").and_then(JsonValue::as_str) {
309        Some("exists") => {
310            if let Some(p) = domain.get("path").and_then(JsonValue::as_str) {
311                if negated {
312                    neg.insert(p.to_string());
313                } else {
314                    pos.insert(p.to_string());
315                }
316            }
317        }
318        Some("all") => {
319            if let Some(JsonValue::Array(items)) = domain.get("inner") {
320                for d in items {
321                    collect_exists_paths(d, negated, pos, neg);
322                }
323            }
324        }
325        // 仅 `not(exists P)` 可证明为负断言;其余 not 内不下钻
326        Some("not")
327            if domain
328                .get("inner")
329                .and_then(|i| i.get("type"))
330                .and_then(JsonValue::as_str)
331                == Some("exists") =>
332        {
333            if let Some(inner) = domain.get("inner") {
334                collect_exists_paths(inner, !negated, pos, neg);
335            }
336        }
337        _ => {}
338    }
339}
340
341/// 两规则 domain 是否**可证明互斥**
342///
343/// 判据:存在同一路径 P,一方断言 `exists P`、另一方断言 `not(exists P)`。
344/// 此时两规则不可能同时命中 ⇒ 早退方构不成对对方的遮蔽。
345/// 这是单向安全的精化:只**减少** shadowed=true 的判定,不新增。
346fn mutually_exclusive(a: &TopFacts, b: &TopFacts) -> bool {
347    !a.pos_exists.is_disjoint(&b.neg_exists) || !b.pos_exists.is_disjoint(&a.neg_exists)
348}
349
350/// 遮蔽源:会抢先返回信号、使后续规则永不求值的顶层规则
351///
352/// **必须与 TCB 内核语义同步**:BUG-P0-005 修复后顶层 `enforce` 走「约束前置门」
353/// 独立求值(约束不是状态变换),不再参与序列内的早退竞争,因此**不是**遮蔽源。
354fn is_shadow_source(top_rule: &JsonValue) -> bool {
355    match node_type(top_rule) {
356        "enforce" => false,
357        _ => is_early_exit(top_rule),
358    }
359}
360
361/// 被校验的一个节点(含所属顶层规则与列表位置等「内核看不见」的事实)
362struct Node<'a> {
363    top: usize,
364    path: String,
365    node: &'a JsonValue,
366    nested: bool,
367    targets: BTreeSet<String>,
368    /// 所属顶层规则树内的 io_request 总数(D4)
369    io_in_tree: usize,
370    /// 同一趟转换中,它之前是否存在可达的改状态操作(D5)
371    ///
372    /// 顶层规则:仅统计**同趟可达**的前序规则(domain 指令类型可证不相交的
373    /// 前序规则在本趟必不执行,见 `flatten` 内注释);嵌套子节点:继承父链
374    /// 事实 + 本级同级前缀(DFS 序单调过近似,见 `child_node` 注释)。
375    preceded_by_mutating: bool,
376}
377
378/// 前序遍历:父节点先入列,再递归 branch 的两个分支
379///
380/// 节点自身的 `preceded_by_mutating` 在构造时已定型(顶层规则 = 同趟可达精化,
381/// 嵌套子节点 = 同级原始前缀),walk 只透传、不重算。
382fn walk<'a>(nd: &Node<'a>, out: &mut Vec<Node<'a>>) {
383    out.push(Node {
384        top: nd.top,
385        path: nd.path.clone(),
386        node: nd.node,
387        nested: nd.nested,
388        targets: nd.targets.clone(),
389        io_in_tree: nd.io_in_tree,
390        preceded_by_mutating: nd.preceded_by_mutating,
391    });
392    if node_type(nd.node) == "branch" {
393        for key in ["on_true", "on_false"] {
394            let children = children_of(nd.node, key);
395            for (j, c) in children.iter().enumerate() {
396                let prior: Vec<&'a JsonValue> = children.iter().take(j).copied().collect();
397                let child = child_node(nd, format!("{}.{key}[{j}]", nd.path), c, true, &prior);
398                walk(&child, out);
399            }
400        }
401    }
402}
403
404/// 从父节点派生子节点描述符(继承顶层下标与目标集合,重置同级前缀)
405///
406/// 同趟事实 = **继承父链** ∪ **本级同级前缀**:branch 是 if/else(两臂互斥,
407/// 不存在「另一臂的 set 在我之后执行」的漏算),DFS 序中父链前的事实必然
408/// 先于本节点——继承是单调的过近似(多报不漏报,保守方向正确)。
409/// 跨顶层规则的嵌套同趟 set(R-B1 验证中发现的盲区)由此覆盖。
410fn child_node<'a>(
411    parent: &Node<'a>,
412    path: String,
413    node: &'a JsonValue,
414    nested: bool,
415    before: &[&'a JsonValue],
416) -> Node<'a> {
417    Node {
418        top: parent.top,
419        path,
420        node,
421        nested,
422        targets: parent.targets.clone(),
423        io_in_tree: parent.io_in_tree,
424        preceded_by_mutating: parent.preceded_by_mutating || before.iter().any(|s| has_mutating(s)),
425    }
426}
427
428fn flatten<'a>(rules: &'a [JsonValue], tops: &[TopFacts]) -> Vec<Node<'a>> {
429    let mut out = Vec::new();
430    for (i, r) in rules.iter().enumerate() {
431        let before: Vec<&JsonValue> = rules.iter().take(i).collect();
432        // 同趟可达精化(report-006 F2 / R-B1 两段式整改的前提):
433        // 一个 transform 内的顶层规则共享同一转换趟,前序规则的 set/push 对
434        // 后序规则的 io 而言是「io 前状态修改」(IoRequired 纯信号会一并丢弃)。
435        // 但指令类型每趟唯一,前序规则在下列**可证明不可能与本规则同趟命中**时,
436        // 其变更构不成同趟前置:
437        //   a) domain 合取式指令类型目标集与本规则可证不相交(双方非空);
438        //   b) 两规则 domain 存在同路径正/负 exists 互斥(R-A4 同一判据)。
439        // 判定只在机制侧供事实,DC 条款 JSON 不动。
440        let preceded_by_mutating = before.iter().enumerate().any(|(j, s)| {
441            has_mutating(s)
442                && maybe_overlap(&tops[j].provable, &tops[i].provable)
443                && !mutually_exclusive(&tops[j], &tops[i])
444        });
445        let root = Node {
446            top: i,
447            path: format!("[{i}]"),
448            node: r,
449            nested: false,
450            targets: tops[i].targets.clone(),
451            io_in_tree: count_io(r),
452            preceded_by_mutating,
453        };
454        walk(&root, &mut out);
455    }
456    out
457}
458
459/// 把「列表级事实」标注进节点副本,交给内核求值(原规则集不被修改)
460fn build_probe(nd: &Node<'_>, tops: &[TopFacts]) -> JsonValue {
461    let owner = tops.get(nd.top);
462    let shadowed = node_type(nd.node) == "io_request"
463        && (0..nd.top).any(|j| match tops.get(j) {
464            Some(t) => {
465                t.shadow_source
466                    && maybe_overlap(&t.targets, &nd.targets)
467                    // 互斥精化(R-A4 / report-006 F1):source 与 owner 的 domain
468                    // 在同一路径上正/负 exists 互斥 ⇒ 不可能同时命中,无遮蔽
469                    && !owner.is_some_and(|o| mutually_exclusive(t, o))
470            }
471            None => false,
472        });
473
474    let mut probe = nd.node.clone();
475    let ctx = JsonValue::object_from_pairs(&[
476        ("path", JsonValue::string(nd.path.clone())),
477        ("nested", JsonValue::bool(nd.nested)),
478        ("shadowed", JsonValue::bool(shadowed)),
479        (
480            "io_in_tree",
481            JsonValue::integer(i64::try_from(nd.io_in_tree).unwrap_or(i64::MAX)),
482        ),
483        (
484            "preceded_by_mutating",
485            JsonValue::bool(nd.preceded_by_mutating),
486        ),
487        ("has_attr", JsonValue::bool(params_has_key(nd.node, "attr"))),
488        (
489            "has_value",
490            JsonValue::bool(params_has_key(nd.node, "value")),
491        ),
492    ]);
493    probe.insert("_ctx".to_string(), ctx);
494    probe
495}
496
497#[cfg(test)]
498mod tests {
499    #![allow(clippy::unwrap_used)]
500    use super::*;
501
502    /// 用纪律自身的序列化路径构造被校验规则集(与 load_rules 同源)
503    fn ruleset(json: &str) -> Vec<JsonValue> {
504        let v: serde_json::Value = serde_json::from_str(json).unwrap();
505        match v {
506            serde_json::Value::Array(a) => a.iter().map(evorule_reactor::serde_to_tcb).collect(),
507            other => vec![evorule_reactor::serde_to_tcb(&other)],
508        }
509    }
510
511    fn codes(violations: &[Violation]) -> Vec<&str> {
512        violations.iter().map(|v| v.code.as_str()).collect()
513    }
514
515    const GOOD: &str = r#"[
516      {"type":"enforce","params":{"domain":{"type":"instruction","instruction_type":"set"},"reason":"r1"}},
517      {"type":"branch","params":{"domain":{"type":"exists","path":"a"},
518        "on_true":[{"type":"set","params":{"attr":"x","value":1}}],
519        "on_false":[{"type":"io_request","params":{"io_type":"llm"}}]}}
520    ]"#;
521
522    #[test]
523    fn good_ruleset_passes_the_gate() {
524        let report = check(&ruleset(GOOD)).unwrap();
525        let v = report.violations;
526        assert!(report.scanned > 0, "应实际扫描到节点");
527        assert!(v.is_empty(), "合规规则集不得被拒载: {v:?}");
528    }
529
530    #[test]
531    fn unknown_meta_instruction_is_rejected() {
532        let v = check(&ruleset(r#"[{"type":"while_loop","params":{}}]"#))
533            .unwrap()
534            .violations;
535        assert_eq!(codes(&v), vec!["DC-01"], "未知元指令应命中 DC-01");
536    }
537
538    #[test]
539    fn nested_enforce_is_rejected() {
540        let json = r#"[{"type":"branch","params":{"domain":{"type":"exists","path":"a"},
541            "on_true":[{"type":"enforce","params":{"domain":{"type":"exists","path":"b"},"reason":"r"}}],
542            "on_false":[]}}]"#;
543        let v = check(&ruleset(json)).unwrap().violations;
544        assert_eq!(codes(&v), vec!["DC-02"], "嵌套 enforce 应命中 DC-02");
545    }
546
547    #[test]
548    fn shadowed_io_request_is_rejected() {
549        // BUG-P0-005 同型:前面的 io_request 抢先返回信号,后面同类型的永远不求值
550        let json = r#"[
551          {"type":"io_request","params":{"io_type":"audit"},
552           "domain":{"type":"instruction","instruction_type":"call_external"}},
553          {"type":"branch","params":{"domain":{"type":"instruction","instruction_type":"call_external"},
554            "on_true":[{"type":"io_request","params":{"io_type":"llm"}}],"on_false":[]}}
555        ]"#;
556        let v = check(&ruleset(json)).unwrap().violations;
557        assert!(
558            codes(&v).contains(&"DC-05"),
559            "被遮蔽的 io_request 应命中 DC-05, got {v:?}"
560        );
561    }
562
563    #[test]
564    fn mutually_exclusive_rules_are_not_shadow_sources() {
565        // report-006 F1 / R-A4:core_eval [8]/[9] 同型 —— source 要求
566        // `exists p.service_name`,owner 要求 `not(exists p.service_name)`,
567        // 两者不可能同时命中 ⇒ 无遮蔽,不得误报 DC-05。
568        // (B 段若在此误报上强制拒载,server 会拒载自己的 core_eval。)
569        let json = r#"[
570          {"type":"branch","params":{"domain":{"type":"all","inner":[
571             {"type":"instruction","instruction_type":"call_service"},
572             {"type":"exists","path":"p.service_name"}]},
573            "on_true":[{"type":"io_request","params":{"io_type":"call_service"}}]}},
574          {"type":"branch","params":{"domain":{"type":"all","inner":[
575             {"type":"instruction","instruction_type":"call_service"},
576             {"type":"not","inner":{"type":"exists","path":"p.service_name"}}]},
577            "on_true":[{"type":"io_request","params":{"io_type":"call_service"}}]}}
578        ]"#;
579        let v = check(&ruleset(json)).unwrap().violations;
580        assert!(v.is_empty(), "互斥规则不得判遮蔽: {v:?}");
581    }
582
583    #[test]
584    fn negated_all_does_not_create_false_exclusion() {
585        // 健全性:`not(all[exists P])` 推不出 `¬exists P`——不得据此豁免遮蔽。
586        // 去掉互斥精化的可证明前提后,保守遮蔽判定必须原样生效。
587        let json = r#"[{"type":"branch","params":{"domain":{"type":"all","inner":[{"type":"instruction","instruction_type":"call_service"},{"type":"exists","path":"p.service_name"}]},"on_true":[{"type":"io_request","params":{"io_type":"call_service"}}]}},{"type":"branch","params":{"domain":{"type":"all","inner":[{"type":"instruction","instruction_type":"call_service"},{"type":"not","inner":{"type":"all","inner":[{"type":"exists","path":"p.service_name"}]}},{"type":"exists","path":"p.tool_name"}]},"on_true":[{"type":"io_request","params":{"io_type":"call_service"}}]}}]"#;
588        let v = check(&ruleset(json)).unwrap().violations;
589        assert!(
590            codes(&v).contains(&"DC-05"),
591            "not(all) 嵌套不构成互斥,保守遮蔽判定应生效: {v:?}"
592        );
593    }
594
595    #[test]
596    fn double_io_in_one_rule_is_rejected() {
597        let json = r#"[{"type":"branch","params":{"domain":{"type":"exists","path":"a"},
598            "on_true":[{"type":"io_request","params":{"io_type":"llm"}}],
599            "on_false":[{"type":"io_request","params":{"io_type":"db"}}]}}]"#;
600        let v = check(&ruleset(json)).unwrap().violations;
601        assert!(
602            codes(&v).iter().filter(|c| **c == "DC-08").count() == 2,
603            "一条规则内两个 io_request 应命中 DC-08 两次, got {v:?}"
604        );
605    }
606
607    #[test]
608    fn io_request_after_mutation_is_rejected() {
609        // TCB_SPEC D5:io_request 前的 set 会被丢弃(IoRequired 是纯信号)→ 重放失真
610        let json = r#"[
611          {"type":"set","params":{"attr":"x","value":1}},
612          {"type":"io_request","params":{"io_type":"llm"}}
613        ]"#;
614        let v = check(&ruleset(json)).unwrap().violations;
615        assert!(
616            codes(&v).contains(&"DC-09"),
617            "io_request 前有改状态操作应命中 DC-09, got {v:?}"
618        );
619    }
620
621    #[test]
622    fn disjoint_top_rule_mutations_are_not_same_pass() {
623        // R-B1 两段式整改的前提:前序顶层规则 [0](domain=foo)含 set,
624        // 后序规则 [1](domain=bar)含 io_request。指令类型每趟唯一,
625        // [0] 在 [1] 命中的那一趟必不执行 ⇒ 其 set 构不成同趟前置,
626        // 不得误报 DC-09。(set→push 两段式:phase1 准备参数,phase2 纯 io。)
627        let json = r#"[{"type":"branch","params":{"domain":{"type":"instruction","instruction_type":"foo"},"on_true":[{"type":"set","params":{"attr":"_args.v","value":1}}],"on_false":[]}},{"type":"branch","params":{"domain":{"type":"instruction","instruction_type":"bar"},"on_true":[{"type":"io_request","params":{"io_type":"call_service","args":"__exec__.payload._args"}}],"on_false":[]}}]"#;
628        let v = check(&ruleset(json)).unwrap().violations;
629        assert!(
630            v.is_empty(),
631            "指令类型不相交的前序规则不构成同趟前置: {v:?}"
632        );
633    }
634
635    #[test]
636    fn overlapping_top_rule_mutations_still_rejected() {
637        // 健全性(对上一测试的反向锁死):前序规则与 io 所属规则目标集相交
638        // (或前序为 ANY 空集)时,可能同一趟执行 ⇒ set 必须照常命中 DC-09。
639        let json = r#"[{"type":"branch","params":{"domain":{"type":"instruction","instruction_type":"foo"},"on_true":[{"type":"set","params":{"attr":"_args.v","value":1}}],"on_false":[]}},{"type":"branch","params":{"domain":{"type":"instruction","instruction_type":"foo"},"on_true":[{"type":"io_request","params":{"io_type":"call_service","args":"__exec__.payload._args"}}],"on_false":[]}}]"#;
640        let v = check(&ruleset(json)).unwrap().violations;
641        assert!(
642            codes(&v).contains(&"DC-09"),
643            "目标集相交的前序 set 仍是同趟前置, got {v:?}"
644        );
645
646        let any_pred = r#"[{"type":"set","params":{"attr":"x","value":1}},{"type":"branch","params":{"domain":{"type":"instruction","instruction_type":"bar"},"on_true":[{"type":"io_request","params":{"io_type":"llm"}}],"on_false":[]}}]"#;
647        let v2 = check(&ruleset(any_pred)).unwrap().violations;
648        assert!(
649            codes(&v2).contains(&"DC-09"),
650            "无指令谓词(ANY)的前序 set 按保守口径仍命中, got {v2:?}"
651        );
652    }
653
654    #[test]
655    fn mutually_exclusive_top_rules_are_not_same_pass() {
656        // 宪法 server_eval [8]/[9] 同型(R-B2 同步时暴露的误报):
657        // [8] call_service ∧ exists service_name(含 set),[9] call_service ∧
658        // not(exists service_name)(含 io)。两规则不可能同趟命中 ⇒ [8] 的 set
659        // 构不成对 [9] 的 io 的同趟前置,不得误报 DC-09。
660        let json = r#"[{"type":"branch","params":{"domain":{"type":"all","inner":[{"type":"instruction","instruction_type":"call_service"},{"type":"exists","path":"__exec__.instruction.params.service_name"}]},"on_true":[{"type":"set","params":{"attr":"x","value":1}}],"on_false":[]}},{"type":"branch","params":{"domain":{"type":"all","inner":[{"type":"instruction","instruction_type":"call_service"},{"type":"not","inner":{"type":"exists","path":"__exec__.instruction.params.service_name"}}]},"on_true":[{"type":"branch","params":{"domain":{"type":"exists","path":"__exec__.payload.__io_results__.call_service"},"on_false":[{"type":"io_request","params":{"io_type":"call_service"}}],"on_true":[]}}],"on_false":[]}}]"#;
661        let v = check(&ruleset(json)).unwrap().violations;
662        assert!(
663            v.is_empty(),
664            "exists/¬exists 互斥的前序规则不构成同趟前置: {v:?}"
665        );
666    }
667
668    #[test]
669    fn missing_type_field_is_rejected() {
670        let v = check(&ruleset(r#"[{"params":{}}]"#)).unwrap().violations;
671        assert_eq!(codes(&v), vec!["DC-01"], "缺 type 字段应命中 DC-01");
672    }
673
674    /// 真实误报复盘:早期 wasm 侧演示曾用 `set value: null` 清空 I/O 结果。
675    /// domain 的 `exists` 语义是「null = 已清除」,第一版 DC-06 因此误报;
676    /// 本测试锁死「字段存在即可,值可为 null」的正确判据。
677    #[test]
678    fn set_with_null_value_is_accepted() {
679        let json = r#"[{"type":"set","params":{"attr":"__io_results__.demo","operation":"set","value":null}}]"#;
680        let v = check(&ruleset(json)).unwrap().violations;
681        assert!(v.is_empty(), "value=null 的 set 不得被拒载: {v:?}");
682    }
683
684    #[test]
685    fn set_without_value_field_is_rejected() {
686        let v = check(&ruleset(r#"[{"type":"set","params":{"attr":"x"}}]"#))
687            .unwrap()
688            .violations;
689        assert_eq!(codes(&v), vec!["DC-06"], "缺 value 字段应命中 DC-06");
690    }
691
692    /// B 段回归锁(design-06 §11):嵌入的纪律数据本身必须能被门禁通过——
693    /// 纪律条款全为顶层 enforce 且带 reason(DC-02/DC-03 自身合规)。
694    #[test]
695    fn embedded_discipline_ruleset_is_self_compliant() {
696        let report = check(&load_discipline().unwrap()).unwrap();
697        assert!(
698            report.violations.is_empty(),
699            "纪律数据自身违规(自指失败): {:?}",
700            report.violations
701        );
702    }
703}