Skip to main content

clawft_kernel/
governance.rs

1//! Constitutional governance engine for WeftOS.
2//!
3//! Implements the three-branch governance model where:
4//! - **Legislative** (SOPs, rules, manifests) defines boundaries
5//! - **Executive** (agents) acts within defined boundaries
6//! - **Judicial** (CGR engine) validates every action
7//!
8//! No branch can modify another's constraints. Governance violations
9//! are type-level impossibilities, not merely audited events.
10//!
11//! # Design
12//!
13//! All types compile unconditionally. The CGR validation engine and
14//! effect algebra scoring require the `governance` or `ruvector-apps`
15//! feature gates. Without them, `GovernanceEngine::evaluate()` returns
16//! `GovernanceDecision::Permit` (open governance).
17
18use std::collections::HashMap;
19
20use serde::{Deserialize, Serialize};
21use tracing::debug;
22
23use crate::environment::{Environment, EnvironmentClass};
24
25/// Discriminator for governance rule evaluation paths.
26///
27/// Most rules use the default effect-magnitude path ([`General`](Self::General)).
28/// [`BrowserPolicy`](Self::BrowserPolicy) rules apply platform-aware checks for
29/// untrusted browser nodes (S7 / WEFT-108) independent of effect magnitude.
30#[non_exhaustive]
31#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum GovernanceRuleType {
34    /// Standard rule evaluated via effect-magnitude threshold.
35    #[default]
36    General,
37    /// Browser-platform sandbox policy (topic scope, elevation, spawn/network).
38    BrowserPolicy,
39    /// Binding-thread integrity on SOUL.md (WEFT-342 / soul.binding_thread_intact).
40    ///
41    /// Evaluated independent of effect magnitude when the action is
42    /// `soul.binding_thread_intact` and the request signals a mismatch
43    /// (elevated security effect and/or `binding_thread_status=mismatch`
44    /// in context).
45    BindingThread,
46}
47
48impl std::fmt::Display for GovernanceRuleType {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            GovernanceRuleType::General => write!(f, "general"),
52            GovernanceRuleType::BrowserPolicy => write!(f, "browser_policy"),
53            GovernanceRuleType::BindingThread => write!(f, "binding_thread"),
54        }
55    }
56}
57
58/// A governance rule that restricts agent behavior.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct GovernanceRule {
61    /// Unique rule identifier.
62    pub id: String,
63
64    /// Human-readable rule description.
65    pub description: String,
66
67    /// Which branch defined this rule.
68    pub branch: GovernanceBranch,
69
70    /// Rule severity (how critical the violation is).
71    pub severity: RuleSeverity,
72
73    /// Whether this rule is currently active.
74    #[serde(default = "default_true")]
75    pub active: bool,
76
77    /// SOP reference URL for agents to consult for full procedure.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub reference_url: Option<String>,
80
81    /// SOP category tag for filtering rules by domain.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub sop_category: Option<String>,
84
85    /// Rule type discriminator. Defaults to [`GovernanceRuleType::General`].
86    ///
87    /// Serde defaults missing fields so older chain-anchored rules remain valid.
88    #[serde(default)]
89    pub rule_type: GovernanceRuleType,
90
91    /// Optional action selector (WEFT-634).
92    ///
93    /// When set, the rule only applies when the request's `action` matches.
94    /// Matching is exact, or prefix when the selector ends with `*`
95    /// (e.g. `"tool.agent_*"` matches `"tool.agent_spawn"`).
96    /// `None` / empty means "match any action" (magnitude-only rules).
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub action_selector: Option<String>,
99
100    /// Optional tool selector (WEFT-634).
101    ///
102    /// When set, the rule only applies when the request context key
103    /// `"tool"` (or fallback `"gate_action"`) matches. Same exact/prefix
104    /// semantics as [`Self::action_selector`]. `None` means "any tool".
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub tool_selector: Option<String>,
107
108    /// When `true`, a selector-matching General rule applies its severity
109    /// **without** requiring effect magnitude to exceed the threshold
110    /// (WEFT-634 / WEFT-633 spawn-approval foundation).
111    ///
112    /// Magnitude-only rules leave this `false` (default) so behaviour is
113    /// backward-compatible. BrowserPolicy / BindingThread paths already
114    /// ignore magnitude and do not consult this flag.
115    #[serde(default)]
116    pub force_on_match: bool,
117}
118
119/// `sop_category` tag of a project-overlay deny rule (ADR-103 D8). A matching
120/// blocking rule is an unconditional `Deny`: the engine-wide
121/// `human_approval_required` flag does not soften it into an escalation.
122pub const OVERLAY_DENY_TAG: &str = "project-overlay:deny";
123
124/// `sop_category` tag of a project-overlay `require_approval` rule (ADR-103
125/// D8). A matching blocking rule yields `EscalateToHuman` even when the
126/// engine-wide `human_approval_required` flag is off.
127pub const OVERLAY_APPROVAL_TAG: &str = "project-overlay:approval";
128
129impl GovernanceRule {
130    /// Get rules by SOP category from a slice of rules.
131    pub fn filter_by_category<'a>(
132        rules: &'a [GovernanceRule],
133        category: &str,
134    ) -> Vec<&'a GovernanceRule> {
135        rules
136            .iter()
137            .filter(|r| r.sop_category.as_deref() == Some(category))
138            .collect()
139    }
140
141    /// Get rules of a given type from a slice of rules.
142    pub fn filter_by_type<'a>(
143        rules: &'a [GovernanceRule],
144        rule_type: &GovernanceRuleType,
145    ) -> Vec<&'a GovernanceRule> {
146        rules
147            .iter()
148            .filter(|r| &r.rule_type == rule_type)
149            .collect()
150    }
151
152    /// Whether this rule's action/tool selectors match `request` (WEFT-634).
153    ///
154    /// - No selectors set → always matches (magnitude-only rule).
155    /// - Action selector: matches `request.action`.
156    /// - Tool selector: matches `context["tool"]`, else `context["gate_action"]`.
157    /// - Both set → both must match (AND).
158    pub fn selectors_match(&self, request: &GovernanceRequest) -> bool {
159        if let Some(sel) = self.action_selector.as_deref()
160            && !selector_matches(sel, &request.action)
161        {
162            return false;
163        }
164        if let Some(sel) = self.tool_selector.as_deref() {
165            let tool = request
166                .context
167                .get("tool")
168                .or_else(|| request.context.get("gate_action"))
169                .map(String::as_str)
170                .unwrap_or("");
171            if !selector_matches(sel, tool) {
172                return false;
173            }
174        }
175        true
176    }
177
178    /// Build a blocking browser_policy rule (S7 / WEFT-108).
179    pub fn browser_policy(id: impl Into<String>, description: impl Into<String>) -> Self {
180        Self {
181            id: id.into(),
182            description: description.into(),
183            branch: GovernanceBranch::Legislative,
184            severity: RuleSeverity::Blocking,
185            active: true,
186            reference_url: None,
187            sop_category: Some("browser_policy".into()),
188            rule_type: GovernanceRuleType::BrowserPolicy,
189            action_selector: None,
190            tool_selector: None,
191            force_on_match: false,
192        }
193    }
194
195    /// Build the binding-thread integrity rule (WEFT-342).
196    ///
197    /// Rule id is the governance action name
198    /// [`BINDING_THREAD_RULE_ID`] (`soul.binding_thread_intact`).
199    pub fn binding_thread(description: impl Into<String>) -> Self {
200        Self {
201            id: BINDING_THREAD_RULE_ID.into(),
202            description: description.into(),
203            branch: GovernanceBranch::Legislative,
204            severity: RuleSeverity::Blocking,
205            active: true,
206            reference_url: None,
207            sop_category: Some("ethics".into()),
208            rule_type: GovernanceRuleType::BindingThread,
209            action_selector: None,
210            tool_selector: None,
211            force_on_match: false,
212        }
213    }
214
215    /// Build the spawn-approval rule (WEFT-633 foundation).
216    ///
217    /// Matches `tool.agent_spawn` / tool context `agent_spawn` and forces
218    /// a blocking decision independent of effect magnitude so the gate
219    /// maps to [`GovernanceDecision::EscalateToHuman`] →
220    /// [`crate::gate::GateDecision::Defer`] when the engine has
221    /// `human_approval_required`. Without human approval, the decision
222    /// is a hard deny (operator must grant via exemption or lower severity).
223    pub fn spawn_requires_approval() -> Self {
224        Self {
225            id: "SPAWN-APPROVAL".into(),
226            description: "Agent spawn requires in-conversation user approval (Defer + grant)"
227                .into(),
228            branch: GovernanceBranch::Legislative,
229            severity: RuleSeverity::Blocking,
230            active: true,
231            reference_url: None,
232            sop_category: Some("multi_agent".into()),
233            rule_type: GovernanceRuleType::General,
234            action_selector: Some("tool.agent_spawn".into()),
235            tool_selector: Some("agent_spawn".into()),
236            force_on_match: true,
237        }
238    }
239}
240
241/// Match a selector against a candidate string (WEFT-634).
242///
243/// - Empty selector → matches anything.
244/// - Trailing `*` → prefix match on the stem (e.g. `tool.*` matches `tool.exec`).
245/// - Otherwise exact equality.
246pub fn selector_matches(selector: &str, candidate: &str) -> bool {
247    if selector.is_empty() {
248        return true;
249    }
250    if let Some(prefix) = selector.strip_suffix('*') {
251        return candidate.starts_with(prefix);
252    }
253    selector == candidate
254}
255
256/// Governance rule / gate action id for binding-thread integrity (WEFT-342).
257pub const BINDING_THREAD_RULE_ID: &str = "soul.binding_thread_intact";
258
259/// Canonical deny reason for binding-thread mismatch (WEFT-342).
260pub const BINDING_THREAD_MISMATCH_REASON: &str = "binding-thread mismatch";
261
262/// Default browser_policy rules anchored at governance genesis (S7).
263///
264/// - **BP-001**: browser nodes may only subscribe/publish public topics
265/// - **BP-002**: browser nodes cannot spawn children without elevation
266/// - **BP-003**: browser nodes cannot enable network without elevation
267pub fn browser_policy_default_rules() -> Vec<GovernanceRule> {
268    vec![
269        GovernanceRule::browser_policy(
270            "BP-001",
271            "Browser nodes may only publish/subscribe to public topics (public.*)",
272        ),
273        GovernanceRule::browser_policy(
274            "BP-002",
275            "Browser nodes cannot spawn child agents without capability elevation",
276        ),
277        GovernanceRule::browser_policy(
278            "BP-003",
279            "Browser nodes cannot enable network access without capability elevation",
280        ),
281    ]
282}
283
284/// Default binding-thread integrity rule (WEFT-342), anchored at genesis.
285///
286/// Evaluated on every `soul.binding_thread_intact` gate.check when the
287/// request signals a SOUL.md mismatch. Hard-refuses with
288/// [`BINDING_THREAD_MISMATCH_REASON`].
289pub fn binding_thread_default_rules() -> Vec<GovernanceRule> {
290    vec![GovernanceRule::binding_thread(
291        "SOUL.md must contain the compile-time binding-thread excerpt; \
292         mismatch hard-refuses agent turns",
293    )]
294}
295
296fn default_true() -> bool {
297    true
298}
299
300/// Governance branch that owns a rule.
301#[non_exhaustive]
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303pub enum GovernanceBranch {
304    /// Rules from SOPs, genesis protocol, weftapp.toml.
305    Legislative,
306    /// Rules from agent execution policies.
307    Executive,
308    /// Rules from CGR validation engine.
309    Judicial,
310}
311
312impl std::fmt::Display for GovernanceBranch {
313    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        match self {
315            GovernanceBranch::Legislative => write!(f, "legislative"),
316            GovernanceBranch::Executive => write!(f, "executive"),
317            GovernanceBranch::Judicial => write!(f, "judicial"),
318        }
319    }
320}
321
322/// Rule violation severity.
323#[non_exhaustive]
324#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
325pub enum RuleSeverity {
326    /// Advisory -- logged but not enforced.
327    Advisory,
328    /// Warning -- logged and flagged, action proceeds.
329    Warning,
330    /// Blocking -- action is prevented.
331    Blocking,
332    /// Critical -- action prevented and agent capability may be revoked.
333    Critical,
334}
335
336impl std::fmt::Display for RuleSeverity {
337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        match self {
339            RuleSeverity::Advisory => write!(f, "advisory"),
340            RuleSeverity::Warning => write!(f, "warning"),
341            RuleSeverity::Blocking => write!(f, "blocking"),
342            RuleSeverity::Critical => write!(f, "critical"),
343        }
344    }
345}
346
347/// 5-dimensional effect vector for scoring agent actions.
348///
349/// Each dimension is scored from 0.0 (no impact) to 1.0 (maximum impact).
350/// The magnitude of the vector determines whether an action exceeds
351/// the environment's governance threshold.
352#[derive(Debug, Clone, Default, Serialize, Deserialize)]
353pub struct EffectVector {
354    /// Risk score: probability of negative outcome.
355    #[serde(default)]
356    pub risk: f64,
357
358    /// Fairness score: impact on equitable treatment.
359    #[serde(default)]
360    pub fairness: f64,
361
362    /// Privacy score: impact on data privacy.
363    #[serde(default)]
364    pub privacy: f64,
365
366    /// Novelty score: how unprecedented the action is.
367    #[serde(default)]
368    pub novelty: f64,
369
370    /// Security score: impact on system security.
371    #[serde(default)]
372    pub security: f64,
373}
374
375impl EffectVector {
376    /// Compute the magnitude of the effect vector (L2 norm).
377    ///
378    /// Preserved as the stable hardcoded scalar. The EML-backed variant
379    /// is [`Self::score`], which consults an optional
380    /// [`GovernanceScorerModel`](crate::eml_kernel::GovernanceScorerModel)
381    /// and falls back to this value when the model is untrained.
382    pub fn magnitude(&self) -> f64 {
383        (self.risk * self.risk
384            + self.fairness * self.fairness
385            + self.privacy * self.privacy
386            + self.novelty * self.novelty
387            + self.security * self.security)
388            .sqrt()
389    }
390
391    /// Compute the composite governance score for this effect vector.
392    ///
393    /// When `model` is `None` or untrained, returns
394    /// [`Self::magnitude`] unchanged (bit-for-bit identical to the
395    /// pre-EML behaviour). When the model is trained, delegates to
396    /// [`GovernanceScorerModel::predict`] which combines the five
397    /// dimensions into a learned scalar.
398    ///
399    /// Without the `ecc` feature the learned path is unavailable and
400    /// this always returns [`Self::magnitude`] (WEFT-114 no-mesh/wasm).
401    ///
402    /// NOTE(eml-swap): wired — Finding #5 (GovernanceScorerModel).
403    #[cfg(feature = "ecc")]
404    pub fn score(&self, model: Option<&crate::eml_kernel::GovernanceScorerModel>) -> f64 {
405        match model {
406            Some(m) => m.predict(
407                self.risk,
408                self.fairness,
409                self.privacy,
410                self.novelty,
411                self.security,
412            ),
413            None => self.magnitude(),
414        }
415    }
416
417    /// Composite score without EML (no `ecc` feature) — L2 magnitude only.
418    #[cfg(not(feature = "ecc"))]
419    pub fn score(&self) -> f64 {
420        self.magnitude()
421    }
422
423    /// Check if any dimension exceeds a threshold.
424    pub fn any_exceeds(&self, threshold: f64) -> bool {
425        self.risk > threshold
426            || self.fairness > threshold
427            || self.privacy > threshold
428            || self.novelty > threshold
429            || self.security > threshold
430    }
431
432    /// Get the maximum dimension value.
433    pub fn max_dimension(&self) -> f64 {
434        [
435            self.risk,
436            self.fairness,
437            self.privacy,
438            self.novelty,
439            self.security,
440        ]
441        .into_iter()
442        .fold(0.0_f64, f64::max)
443    }
444
445    /// Serialize into the gate-context `"effect"` object shape expected by
446    /// [`crate::gate::GovernanceGate::extract_effect`].
447    pub fn to_context_json(&self) -> serde_json::Value {
448        serde_json::json!({
449            "risk": self.risk,
450            "fairness": self.fairness,
451            "privacy": self.privacy,
452            "novelty": self.novelty,
453            "security": self.security,
454        })
455    }
456
457    /// Explicit EffectVector schema for a privileged gate family (WEFT-506).
458    ///
459    /// Dimensions are auditable constants — not inferred from free-form
460    /// context — so governance decisions remain explainable in chain logs.
461    pub fn for_gate(kind: GateEffectKind) -> Self {
462        kind.effect_vector()
463    }
464}
465
466/// Privileged gate families that must publish an explicit [`EffectVector`]
467/// (WEFT-506). Cron already had inline vectors; auth / config / a2a share
468/// this enum so tests can pin dimensions per gate type.
469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470pub enum GateEffectKind {
471    /// Credential registration (auth).
472    AuthCredentialRegister,
473    /// Credential rotation (auth).
474    AuthCredentialRotate,
475    /// Token issuance (auth).
476    AuthTokenIssue,
477    /// Token revocation (auth).
478    AuthTokenRevoke,
479    /// Config namespace write.
480    ConfigSet,
481    /// Config namespace delete (including typed).
482    ConfigDelete,
483    /// Secret store write.
484    ConfigSecretSet,
485    /// A2A routing-time IPC gate.
486    A2aRouting,
487    /// Cron job add.
488    CronAdd,
489    /// Cron job remove.
490    CronRemove,
491}
492
493impl GateEffectKind {
494    /// Fixed 5-D weights for this gate family.
495    pub fn effect_vector(self) -> EffectVector {
496        match self {
497            // Auth: high security / privacy; moderate risk.
498            Self::AuthCredentialRegister => EffectVector {
499                risk: 0.5,
500                fairness: 0.0,
501                privacy: 0.6,
502                novelty: 0.1,
503                security: 0.8,
504            },
505            Self::AuthCredentialRotate => EffectVector {
506                risk: 0.55,
507                fairness: 0.0,
508                privacy: 0.5,
509                novelty: 0.15,
510                security: 0.85,
511            },
512            Self::AuthTokenIssue => EffectVector {
513                risk: 0.4,
514                fairness: 0.0,
515                privacy: 0.4,
516                novelty: 0.05,
517                security: 0.7,
518            },
519            Self::AuthTokenRevoke => EffectVector {
520                risk: 0.45,
521                fairness: 0.1,
522                privacy: 0.2,
523                novelty: 0.05,
524                security: 0.75,
525            },
526            // Config: medium risk; secret writes spike security/privacy.
527            Self::ConfigSet => EffectVector {
528                risk: 0.35,
529                fairness: 0.0,
530                privacy: 0.2,
531                novelty: 0.15,
532                security: 0.4,
533            },
534            Self::ConfigDelete => EffectVector {
535                risk: 0.4,
536                fairness: 0.0,
537                privacy: 0.15,
538                novelty: 0.1,
539                security: 0.45,
540            },
541            Self::ConfigSecretSet => EffectVector {
542                risk: 0.55,
543                fairness: 0.0,
544                privacy: 0.85,
545                novelty: 0.1,
546                security: 0.9,
547            },
548            // A2A routing: low baseline; tool/signal variants can layer on top.
549            Self::A2aRouting => EffectVector {
550                risk: 0.25,
551                fairness: 0.0,
552                privacy: 0.1,
553                novelty: 0.05,
554                security: 0.3,
555            },
556            // Cron: matches prior inline constants (risk=0.2, security=0.1).
557            Self::CronAdd => EffectVector {
558                risk: 0.2,
559                fairness: 0.0,
560                privacy: 0.0,
561                novelty: 0.1,
562                security: 0.1,
563            },
564            Self::CronRemove => EffectVector {
565                risk: 0.2,
566                fairness: 0.0,
567                privacy: 0.0,
568                novelty: 0.05,
569                security: 0.1,
570            },
571        }
572    }
573
574    /// Action string prefix associated with this gate family.
575    pub fn action_prefix(self) -> &'static str {
576        match self {
577            Self::AuthCredentialRegister
578            | Self::AuthCredentialRotate
579            | Self::AuthTokenIssue
580            | Self::AuthTokenRevoke => "auth.",
581            Self::ConfigSet | Self::ConfigDelete | Self::ConfigSecretSet => "config.",
582            Self::A2aRouting => "ipc.",
583            Self::CronAdd | Self::CronRemove => "cron.",
584        }
585    }
586}
587
588/// Merge an explicit [`EffectVector`] into a gate context JSON object.
589pub fn with_effect_context(mut base: serde_json::Value, effect: &EffectVector) -> serde_json::Value {
590    if let Some(obj) = base.as_object_mut() {
591        obj.insert("effect".into(), effect.to_context_json());
592    }
593    base
594}
595
596/// Governance decision for an action.
597#[non_exhaustive]
598#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
599pub enum GovernanceDecision {
600    /// Action is permitted.
601    Permit,
602    /// Action is permitted with advisory note.
603    PermitWithWarning(String),
604    /// Action requires human approval before proceeding.
605    EscalateToHuman(String),
606    /// Action is denied.
607    Deny(String),
608}
609
610impl std::fmt::Display for GovernanceDecision {
611    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
612        match self {
613            GovernanceDecision::Permit => write!(f, "permit"),
614            GovernanceDecision::PermitWithWarning(msg) => write!(f, "permit (warning: {msg})"),
615            GovernanceDecision::EscalateToHuman(msg) => write!(f, "escalate ({msg})"),
616            GovernanceDecision::Deny(reason) => write!(f, "deny: {reason}"),
617        }
618    }
619}
620
621/// Attributed principal for a governance / gate evaluation (WEFT-636).
622///
623/// Control already holds via `agent_id`; this type makes **who** authorized
624/// the action and **which child** is acting first-class so chain records
625/// and multi-tenant spawn paths carry attribution, not just a bare agent id.
626#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
627pub struct GatePrincipal {
628    /// Acting agent id (child or concierge). Always the evaluation principal
629    /// key when non-empty (see [`GovernanceEngine::eval_principal_key`]).
630    #[serde(default)]
631    pub agent_id: String,
632
633    /// Human / user authority under which the agent acts (spawn-at-user-level).
634    #[serde(default, skip_serializing_if = "Option::is_none")]
635    pub user_id: Option<String>,
636
637    /// Parent agent when this evaluation is for a spawned child.
638    #[serde(default, skip_serializing_if = "Option::is_none")]
639    pub parent_agent_id: Option<String>,
640
641    /// Conversation scope for attribution (parent or child conv id).
642    #[serde(default, skip_serializing_if = "Option::is_none")]
643    pub conv_id: Option<String>,
644
645    /// Project the action is attributed to (ADR-103 A6). Set only from a
646    /// verified source, via [`GatePrincipal::with_project`]; `None` for
647    /// everything that predates Phase 2, which keeps old chain payloads
648    /// byte-identical.
649    ///
650    /// Serialised, never deserialised: no payload can assert a project.
651    #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
652    project_id: Option<String>,
653
654    /// Node id of the kernel that evaluated the request (the project key id
655    /// for a child kernel).
656    /// Serialised, never deserialised, like `project_id`.
657    #[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
658    instance_id: Option<String>,
659}
660
661/// Where a [`ProjectAttestation`] came from.
662#[derive(Debug, Clone, Copy, PartialEq, Eq)]
663pub enum AttestSource {
664    /// The kernel's own bound project (the socket reached is the project's).
665    BoundKernel,
666    /// A validated token whose scope names the project.
667    TokenScope,
668    /// A user-daemon forward header whose user signature was checked.
669    UserForward,
670}
671
672/// A project id whose provenance is cryptographic, ready to stamp onto a
673/// [`GatePrincipal`] with [`GatePrincipal::with_project`].
674///
675/// Cross-crate sealing is not possible here: the daemon (another crate)
676/// is what verifies the sources, so this constructor has to be public.
677/// The guard is therefore by construction path, not by the compiler: the
678/// daemon's `VerifiedProject::attest` is the only caller, a `ClaimedProject`
679/// has no route to it, and package I adds a grep test that no other site
680/// calls [`ProjectAttestation::from_verified`]. A same-uid process can still
681/// claim a project over the socket; this is an isolation guard between one
682/// user's projects, not a boundary against a hostile local process.
683#[derive(Debug, Clone, PartialEq, Eq)]
684pub struct ProjectAttestation {
685    project_id: String,
686    source: AttestSource,
687}
688
689impl ProjectAttestation {
690    /// Wrap a project id the caller has ALREADY verified from `source`.
691    /// Never call this with a client-supplied `Request.project`.
692    pub fn from_verified(project_id: impl Into<String>, source: AttestSource) -> Self {
693        Self {
694            project_id: project_id.into(),
695            source,
696        }
697    }
698
699    /// The verified project id.
700    pub fn project_id(&self) -> &str {
701        &self.project_id
702    }
703
704    /// Which verified source produced it.
705    pub fn source(&self) -> AttestSource {
706        self.source
707    }
708}
709
710impl GatePrincipal {
711    /// Build a principal for a bare agent (no user / parent attribution).
712    pub fn agent(agent_id: impl Into<String>) -> Self {
713        Self {
714            agent_id: agent_id.into(),
715            user_id: None,
716            parent_agent_id: None,
717            conv_id: None,
718            project_id: None,
719            instance_id: None,
720        }
721    }
722
723    /// Attribute the action to a verified project (ADR-103 A6).
724    pub fn with_project(mut self, project: &ProjectAttestation) -> Self {
725        self.project_id = Some(project.project_id().to_owned());
726        self
727    }
728
729    /// Drop project and instance attribution (a kernel with no attestation
730    /// carries none, whatever the request claimed).
731    pub(crate) fn without_attribution(mut self) -> Self {
732        self.project_id = None;
733        self.instance_id = None;
734        self
735    }
736
737    /// The attributed project, if one was verified.
738    pub fn project_id(&self) -> Option<&str> {
739        self.project_id.as_deref()
740    }
741
742    /// The evaluating kernel's node id, if recorded.
743    pub fn instance_id(&self) -> Option<&str> {
744        self.instance_id.as_deref()
745    }
746
747    /// Record the evaluating kernel's node id.
748    pub fn with_instance(mut self, instance_id: impl Into<String>) -> Self {
749        self.instance_id = Some(instance_id.into());
750        self
751    }
752
753    /// Attach a user authority (WEFT-635 / WEFT-636).
754    pub fn with_user(mut self, user_id: impl Into<String>) -> Self {
755        self.user_id = Some(user_id.into());
756        self
757    }
758
759    /// Attach parent agent attribution for a child spawn.
760    pub fn with_parent(mut self, parent_agent_id: impl Into<String>) -> Self {
761        self.parent_agent_id = Some(parent_agent_id.into());
762        self
763    }
764
765    /// Attach conversation scope.
766    pub fn with_conv(mut self, conv_id: impl Into<String>) -> Self {
767        self.conv_id = Some(conv_id.into());
768        self
769    }
770
771    /// Effective rate-limit / evaluation key: prefer non-empty `agent_id`.
772    pub fn eval_key(&self) -> &str {
773        if !self.agent_id.is_empty() {
774            self.agent_id.as_str()
775        } else {
776            self.user_id.as_deref().unwrap_or("")
777        }
778    }
779}
780
781/// Governance evaluation request.
782#[derive(Debug, Clone, Serialize, Deserialize)]
783pub struct GovernanceRequest {
784    /// Agent identifier making the request.
785    pub agent_id: String,
786
787    /// Action being requested.
788    pub action: String,
789
790    /// Computed effect vector for the action.
791    #[serde(default)]
792    pub effect: EffectVector,
793
794    /// Additional context for the evaluator.
795    #[serde(default)]
796    pub context: std::collections::HashMap<String, String>,
797
798    /// Node ID of the requesting node (for distributed governance in K6).
799    #[serde(default, skip_serializing_if = "Option::is_none")]
800    pub node_id: Option<String>,
801
802    /// Optional attributed principal (WEFT-636). When set, propagates onto
803    /// [`GovernanceResult::principal`] and chain audit payloads. When
804    /// absent, the engine synthesises a principal from `agent_id` alone.
805    #[serde(default, skip_serializing_if = "Option::is_none")]
806    pub principal: Option<GatePrincipal>,
807}
808
809impl GovernanceRequest {
810    /// Create a new governance request.
811    pub fn new(agent_id: impl Into<String>, action: impl Into<String>) -> Self {
812        let agent_id = agent_id.into();
813        Self {
814            agent_id: agent_id.clone(),
815            action: action.into(),
816            effect: EffectVector::default(),
817            context: std::collections::HashMap::new(),
818            node_id: None,
819            principal: Some(GatePrincipal::agent(agent_id)),
820        }
821        .attributed()
822    }
823
824    /// Set the node ID for distributed governance evaluation.
825    pub fn with_node_id(mut self, node_id: impl Into<String>) -> Self {
826        self.node_id = Some(node_id.into());
827        self
828    }
829
830    /// Set the effect vector.
831    pub fn with_effect(mut self, effect: EffectVector) -> Self {
832        self.effect = effect;
833        self
834    }
835
836    /// Set the attributed principal (WEFT-636).
837    pub fn with_principal(mut self, principal: GatePrincipal) -> Self {
838        if self.agent_id.is_empty() && !principal.agent_id.is_empty() {
839            self.agent_id = principal.agent_id.clone();
840        }
841        self.principal = Some(principal);
842        self
843    }
844
845    /// Enrich the request with tool execution context (k3:D2).
846    ///
847    /// Sets the `context` map with tool-specific fields so governance
848    /// rules can distinguish between different tool invocations even
849    /// when the action string is the generic `"tool.exec"`.
850    ///
851    /// # Fields set
852    ///
853    /// - `tool` — tool name (e.g. `"fs.read_file"`)
854    /// - `gate_action` — the per-tool gate action from the catalog
855    /// - `pid` — stringified PID of the requesting agent
856    ///
857    /// The `effect` field is set from the tool's declared effect vector,
858    /// enabling threshold-based governance that varies per tool.
859    pub fn with_tool_context(
860        mut self,
861        tool_name: impl Into<String>,
862        gate_action: impl Into<String>,
863        effect: EffectVector,
864        pid: u64,
865    ) -> Self {
866        self.context.insert("tool".into(), tool_name.into());
867        self.context
868            .insert("gate_action".into(), gate_action.into());
869        self.context.insert("pid".into(), pid.to_string());
870        self.effect = effect;
871        self
872    }
873
874    /// Add a single key-value pair to the context map.
875    ///
876    /// The reserved keys ([`crate::governance_project::RESERVED_CONTEXT_KEYS`])
877    /// are the kernel's alone and are ignored here.
878    pub fn with_context_entry(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
879        let key = key.into();
880        if !crate::governance_project::RESERVED_CONTEXT_KEYS.contains(&key.as_str()) {
881            self.context.insert(key, value.into());
882        }
883        self
884    }
885
886    /// Resolve the principal for this request (explicit or synthesised),
887    /// attributed to the kernel's attested project when it has one.
888    pub fn resolved_principal(&self) -> GatePrincipal {
889        let p = self.base_principal();
890        match crate::governance_project::instance_project() {
891            Some((att, inst)) => p.with_project(att).with_instance(inst),
892            None => p,
893        }
894    }
895
896    /// The principal as the request carries it, before project attribution.
897    pub(crate) fn base_principal(&self) -> GatePrincipal {
898        if let Some(p) = &self.principal {
899            let mut p = p.clone();
900            if p.agent_id.is_empty() {
901                p.agent_id = self.agent_id.clone();
902            }
903            return p;
904        }
905        GatePrincipal::agent(self.agent_id.clone())
906    }
907}
908
909/// Evaluate a binding-thread integrity rule (WEFT-342).
910///
911/// Returns `Some(reason)` when the request is a
912/// `soul.binding_thread_intact` check that signals mismatch.
913/// Independent of effect-magnitude thresholds (like browser_policy).
914fn apply_binding_thread_rule(
915    rule: &GovernanceRule,
916    request: &GovernanceRequest,
917) -> Option<String> {
918    if request.action != BINDING_THREAD_RULE_ID && request.action != rule.id {
919        return None;
920    }
921
922    let status = request
923        .context
924        .get("binding_thread_status")
925        .map(|s| s.as_str())
926        .unwrap_or("");
927    let mismatch = status == "mismatch"
928        // Agent loop / KernelEffectGate signal mismatch via elevated
929        // security effect from `effect_for_binding_thread(false)`.
930        || request.effect.security >= 0.99
931        || (status.is_empty() && request.effect.magnitude() > 0.8);
932
933    if mismatch {
934        Some(BINDING_THREAD_MISMATCH_REASON.to_string())
935    } else {
936        None
937    }
938}
939
940/// Evaluate a single browser_policy rule against a request.
941///
942/// Returns `Some(reason)` when the rule fires a denial, `None` when the rule
943/// does not apply (wrong platform / action) or permits the action.
944fn apply_browser_policy_rule(
945    rule: &GovernanceRule,
946    request: &GovernanceRequest,
947) -> Option<String> {
948    let platform = request
949        .context
950        .get("platform")
951        .map(|s| s.as_str())
952        .unwrap_or("");
953
954    // Browser policy only constrains browser-platform principals.
955    if platform != "browser" {
956        return None;
957    }
958
959    let action = request.action.as_str();
960
961    // Topic subscribe/publish restricted to public topics (BP-001).
962    if action.starts_with("ipc.topic")
963        || action == "ipc.subscribe"
964        || action == "ipc.publish"
965        || action == "topic.subscribe"
966        || action == "topic.publish"
967    {
968        let topic = request
969            .context
970            .get("topic")
971            .map(|s| s.as_str())
972            .unwrap_or("");
973        if !crate::capability::is_public_topic(topic) {
974            return Some(format!(
975                "browser_policy '{}': browser nodes may only access public topics (got '{topic}')",
976                rule.id
977            ));
978        }
979        return None;
980    }
981
982    // Spawn blocked for browser without elevation (BP-002).
983    if (action == "agent.spawn"
984        || action == "tool.agent_spawn"
985        || action.starts_with("agent.spawn"))
986        && (rule.id == "BP-002" || rule.description.to_lowercase().contains("spawn"))
987    {
988        return Some(format!(
989            "browser_policy '{}': browser nodes cannot spawn without elevation",
990            rule.id
991        ));
992    }
993
994    // Network blocked for browser without elevation (BP-003).
995    if (action == "network.enable" || action == "capability.network")
996        && (rule.id == "BP-003" || rule.description.to_lowercase().contains("network"))
997    {
998        return Some(format!(
999            "browser_policy '{}': browser nodes cannot enable network without elevation",
1000            rule.id
1001        ));
1002    }
1003
1004    // Context-driven checks when elevating caps (spawn/network flags).
1005    if action == "capability.elevate" {
1006        if request.context.get("can_spawn").map(|s| s.as_str()) == Some("true")
1007            && (rule.id == "BP-002" || rule.description.to_lowercase().contains("spawn"))
1008        {
1009            return Some(format!(
1010                "browser_policy '{}': browser nodes cannot spawn without elevation",
1011                rule.id
1012            ));
1013        }
1014        if request.context.get("can_network").map(|s| s.as_str()) == Some("true")
1015            && (rule.id == "BP-003" || rule.description.to_lowercase().contains("network"))
1016        {
1017            return Some(format!(
1018                "browser_policy '{}': browser nodes cannot enable network without elevation",
1019                rule.id
1020            ));
1021        }
1022    }
1023
1024    None
1025}
1026
1027/// Governance evaluation result.
1028#[derive(Debug, Clone, Serialize, Deserialize)]
1029pub struct GovernanceResult {
1030    /// The decision.
1031    pub decision: GovernanceDecision,
1032
1033    /// Rules that were evaluated.
1034    pub evaluated_rules: Vec<String>,
1035
1036    /// The effect vector that was scored.
1037    pub effect: EffectVector,
1038
1039    /// Whether the effect magnitude exceeded the threshold.
1040    pub threshold_exceeded: bool,
1041
1042    /// Principal attribution for this verdict (WEFT-636).
1043    ///
1044    /// Always populated by [`GovernanceEngine::evaluate`] — either from
1045    /// the request's explicit principal or synthesised from `agent_id`.
1046    #[serde(default, skip_serializing_if = "Option::is_none")]
1047    pub principal: Option<GatePrincipal>,
1048}
1049
1050/// Governance engine.
1051///
1052/// Evaluates actions against governance rules and the environment's
1053/// risk threshold. Without the `governance` feature gate, all
1054/// evaluations return `Permit`.
1055///
1056/// Evaluation requests are rate-limited **per principal** (WEFT-148)
1057/// to stop flood/DoS against the governance path. Defaults are
1058/// generous enough for normal agent tool loops; configure via
1059/// [`GovernanceEngine::with_eval_rate_limit`].
1060pub struct GovernanceEngine {
1061    rules: Vec<GovernanceRule>,
1062    risk_threshold: f64,
1063    human_approval_required: bool,
1064    /// Per-principal evaluation rate limiter (WEFT-148).
1065    eval_limiter: crate::rate_limit::KeyedRateLimiter,
1066    /// Optional learned scorer (Finding #5). When `None` the engine
1067    /// scores effect vectors with the hardcoded L2 norm; when set and
1068    /// trained the scorer's learned composite is used instead.
1069    /// Untrained scorers fall back to L2 (see
1070    /// [`EffectVector::score`]), so this is drop-in safe.
1071    /// Only present under the `ecc` feature (WEFT-114).
1072    #[cfg(feature = "ecc")]
1073    scorer: Option<crate::eml_kernel::GovernanceScorerModel>,
1074}
1075
1076impl GovernanceEngine {
1077    /// Create a governance engine with the given risk threshold.
1078    pub fn new(risk_threshold: f64, human_approval_required: bool) -> Self {
1079        Self {
1080            rules: Vec::new(),
1081            risk_threshold,
1082            human_approval_required,
1083            eval_limiter: crate::rate_limit::KeyedRateLimiter::new(
1084                crate::rate_limit::DEFAULT_GOVERNANCE_EVAL_RATE,
1085            ),
1086            #[cfg(feature = "ecc")]
1087            scorer: None,
1088        }
1089    }
1090
1091    /// Create an open governance engine that permits everything.
1092    ///
1093    /// Still rate-limits evaluations per principal by default so an
1094    /// open policy cannot be used as a free DoS amplifier.
1095    pub fn open() -> Self {
1096        Self {
1097            rules: Vec::new(),
1098            risk_threshold: 1.0,
1099            human_approval_required: false,
1100            eval_limiter: crate::rate_limit::KeyedRateLimiter::new(
1101                crate::rate_limit::DEFAULT_GOVERNANCE_EVAL_RATE,
1102            ),
1103            #[cfg(feature = "ecc")]
1104            scorer: None,
1105        }
1106    }
1107
1108    /// Configure the per-principal evaluation rate limit (WEFT-148).
1109    ///
1110    /// `RateLimitConfig::unlimited()` disables enforcement (tests).
1111    pub fn with_eval_rate_limit(
1112        self,
1113        config: crate::rate_limit::RateLimitConfig,
1114    ) -> Self {
1115        self.set_eval_rate_limit(config);
1116        self
1117    }
1118
1119    /// Replace the evaluation rate-limit configuration in place.
1120    pub fn set_eval_rate_limit(&self, config: crate::rate_limit::RateLimitConfig) {
1121        self.eval_limiter.set_config(config);
1122    }
1123
1124    /// Current evaluation rate-limit configuration.
1125    pub fn eval_rate_limit(&self) -> crate::rate_limit::RateLimitConfig {
1126        self.eval_limiter.config()
1127    }
1128
1129    /// Resolve the principal key used for evaluation rate limiting.
1130    ///
1131    /// Prefers non-empty `agent_id`; then explicit principal's agent/user;
1132    /// falls back to `node_id`.
1133    pub fn eval_principal_key(request: &GovernanceRequest) -> &str {
1134        if !request.agent_id.is_empty() {
1135            return request.agent_id.as_str();
1136        }
1137        if let Some(p) = request.principal.as_ref() {
1138            let k = p.eval_key();
1139            if !k.is_empty() {
1140                return k;
1141            }
1142        }
1143        request.node_id.as_deref().unwrap_or("")
1144    }
1145
1146    /// Install a learned [`GovernanceScorerModel`](crate::eml_kernel::GovernanceScorerModel).
1147    ///
1148    /// With a scorer installed, [`evaluate`](Self::evaluate) uses
1149    /// [`EffectVector::score`] which delegates to the model when
1150    /// trained and falls back to the L2 magnitude otherwise. The
1151    /// fallback path is bit-for-bit identical to the pre-EML
1152    /// behaviour.
1153    #[cfg(feature = "ecc")]
1154    pub fn with_scorer(mut self, scorer: crate::eml_kernel::GovernanceScorerModel) -> Self {
1155        self.scorer = Some(scorer);
1156        self
1157    }
1158
1159    /// Returns a reference to the installed governance scorer model,
1160    /// if any.
1161    #[cfg(feature = "ecc")]
1162    pub fn scorer(&self) -> Option<&crate::eml_kernel::GovernanceScorerModel> {
1163        self.scorer.as_ref()
1164    }
1165
1166    /// Add a governance rule.
1167    pub fn add_rule(&mut self, rule: GovernanceRule) {
1168        debug!(rule_id = %rule.id, branch = %rule.branch, "adding governance rule");
1169        self.rules.push(rule);
1170    }
1171
1172    /// Take over the runtime configuration of the engine this one replaces
1173    /// (evaluation rate limit and, under `ecc`, the learned scorer). Rules and
1174    /// thresholds are not touched.
1175    #[cfg(feature = "exochain")]
1176    pub(crate) fn inherit_config(&mut self, old: &GovernanceEngine) {
1177        self.set_eval_rate_limit(old.eval_rate_limit());
1178        #[cfg(feature = "ecc")]
1179        {
1180            self.scorer = old.scorer.clone();
1181        }
1182    }
1183
1184    /// Every rule, active or not, in insertion order.
1185    pub fn rules(&self) -> &[GovernanceRule] {
1186        &self.rules
1187    }
1188
1189    /// Whether blocking verdicts escalate to a human instead of denying.
1190    pub fn human_approval_required(&self) -> bool {
1191        self.human_approval_required
1192    }
1193
1194    /// Get all active rules.
1195    pub fn active_rules(&self) -> Vec<&GovernanceRule> {
1196        self.rules.iter().filter(|r| r.active).collect()
1197    }
1198
1199    /// Get rules by branch.
1200    pub fn rules_by_branch(&self, branch: &GovernanceBranch) -> Vec<&GovernanceRule> {
1201        self.rules
1202            .iter()
1203            .filter(|r| r.active && &r.branch == branch)
1204            .collect()
1205    }
1206
1207    /// Evaluate a governance request.
1208    ///
1209    /// Decision logic:
1210    /// 0. **Rate limit** (WEFT-148): if the principal has exceeded the
1211    ///    configured evaluation quota, return `Deny("rate limited: …")`
1212    ///    without scoring rules.
1213    /// 1. If any active [`GovernanceRuleType::BrowserPolicy`] rule denies the
1214    ///    action (platform-aware; independent of effect magnitude), deny.
1215    /// 2. If any active [`GovernanceRuleType::BindingThread`] rule denies
1216    ///    the action (WEFT-342; independent of magnitude), deny with
1217    ///    `binding-thread mismatch`.
1218    /// 3. **Action/tool selectors** (WEFT-634): General rules whose selectors
1219    ///    do not match the request are skipped (still listed in
1220    ///    `evaluated_rules` only when they match — non-matching rules are
1221    ///    not counted as evaluated for the verdict).
1222    /// 4. If any blocking/critical rule applies and (magnitude exceeds
1223    ///    threshold **or** `force_on_match`), deny (or escalate when human
1224    ///    approval is required).
1225    /// 5. If any warning rule applies and magnitude exceeds threshold, permit
1226    ///    with warning.
1227    /// 6. Otherwise permit.
1228    ///
1229    /// NOTE(eml-swap): wired — Finding #5 (GovernanceScorerModel).
1230    /// The scalar fed into the threshold check is produced by
1231    /// [`EffectVector::score`], which consults the engine's optional
1232    /// scorer and falls back to the L2 magnitude when no model is
1233    /// installed or the model is untrained.
1234    pub fn evaluate(&self, request: &GovernanceRequest) -> GovernanceResult {
1235        let attributed = request.resolved_principal();
1236        // WEFT-148: per-principal evaluation rate limit.
1237        let principal = Self::eval_principal_key(request);
1238        if !self.eval_limiter.check(principal) {
1239            debug!(
1240                principal = %principal,
1241                action = %request.action,
1242                "governance evaluation rate limited"
1243            );
1244            return GovernanceResult {
1245                decision: GovernanceDecision::Deny(format!(
1246                    "rate limited: evaluation quota exceeded for principal '{principal}'"
1247                )),
1248                evaluated_rules: Vec::new(),
1249                effect: request.effect.clone(),
1250                threshold_exceeded: false,
1251                principal: Some(attributed),
1252            };
1253        }
1254
1255        #[cfg(feature = "ecc")]
1256        let magnitude = request.effect.score(self.scorer.as_ref());
1257        #[cfg(not(feature = "ecc"))]
1258        let magnitude = request.effect.score();
1259        let threshold_exceeded = magnitude > self.risk_threshold;
1260
1261        let mut evaluated_rules = Vec::new();
1262        let mut has_warning = false;
1263        let mut has_blocking = false;
1264        let mut hard_deny = false;
1265        let mut needs_approval = false;
1266        // A blocking rule that is not an overlay approval matched: its verdict
1267        // stands and an approval rule may not soften it into an escalation.
1268        let mut other_blocking = false;
1269        let mut blocking_reason = String::new();
1270
1271        for rule in self.active_rules() {
1272            // WEFT-634: selector filter (applies to all rule types that
1273            // still want action/tool scoping; browser/binding also honor
1274            // selectors when set so an operator can narrow them).
1275            if !rule.selectors_match(request) {
1276                continue;
1277            }
1278            evaluated_rules.push(rule.id.clone());
1279
1280            // Browser policy rules fire on platform/context match, independent
1281            // of effect magnitude (S7 / WEFT-108).
1282            if rule.rule_type == GovernanceRuleType::BrowserPolicy {
1283                if let Some(reason) = apply_browser_policy_rule(rule, request) {
1284                    has_blocking = true;
1285                    other_blocking = true;
1286                    hard_deny |= rule.sop_category.as_deref() == Some(OVERLAY_DENY_TAG);
1287                    blocking_reason = reason;
1288                    // Keep scanning so evaluated_rules is complete, but the
1289                    // first denial reason is retained for the decision text.
1290                    if !matches!(
1291                        rule.severity,
1292                        RuleSeverity::Blocking | RuleSeverity::Critical
1293                    ) {
1294                        // Non-blocking browser_policy still records the hit as warning.
1295                        has_warning = true;
1296                        has_blocking = false;
1297                    }
1298                }
1299                continue;
1300            }
1301
1302            // Binding-thread integrity (WEFT-342): independent of magnitude.
1303            if rule.rule_type == GovernanceRuleType::BindingThread {
1304                if let Some(reason) = apply_binding_thread_rule(rule, request) {
1305                    match rule.severity {
1306                        RuleSeverity::Blocking | RuleSeverity::Critical => {
1307                            has_blocking = true;
1308                            other_blocking = true;
1309                            hard_deny |= rule.sop_category.as_deref() == Some(OVERLAY_DENY_TAG);
1310                            // Canonical reason wins for the deny path AC
1311                            // (`Deny { reason: "binding-thread mismatch" }`),
1312                            // even if a later magnitude rule would overwrite.
1313                            blocking_reason = reason;
1314                        }
1315                        RuleSeverity::Warning => {
1316                            has_warning = true;
1317                        }
1318                        RuleSeverity::Advisory => {}
1319                    }
1320                }
1321                continue;
1322            }
1323
1324            // General rules: magnitude path, or force_on_match (WEFT-634/633).
1325            let applies = threshold_exceeded || rule.force_on_match;
1326            match rule.severity {
1327                RuleSeverity::Blocking | RuleSeverity::Critical => {
1328                    if applies {
1329                        has_blocking = true;
1330                        match rule.sop_category.as_deref() {
1331                            Some(OVERLAY_DENY_TAG) => hard_deny = true,
1332                            Some(OVERLAY_APPROVAL_TAG) => needs_approval = true,
1333                            _ => other_blocking = true,
1334                        }
1335                        blocking_reason = if rule.force_on_match && !threshold_exceeded {
1336                            format!(
1337                                "rule '{}': selector force-match on action '{}' / tool",
1338                                rule.id, request.action
1339                            )
1340                        } else {
1341                            format!(
1342                                "rule '{}': effect magnitude {magnitude:.2} > threshold {:.2}",
1343                                rule.id, self.risk_threshold
1344                            )
1345                        };
1346                    }
1347                }
1348                RuleSeverity::Warning => {
1349                    if applies {
1350                        has_warning = true;
1351                    }
1352                }
1353                RuleSeverity::Advisory => {}
1354            }
1355        }
1356
1357        let decision = if has_blocking {
1358            if !hard_deny && (self.human_approval_required || (needs_approval && !other_blocking)) {
1359                GovernanceDecision::EscalateToHuman(blocking_reason)
1360            } else {
1361                GovernanceDecision::Deny(blocking_reason)
1362            }
1363        } else if threshold_exceeded && has_warning {
1364            GovernanceDecision::PermitWithWarning(format!(
1365                "effect magnitude {magnitude:.2} approaches threshold {:.2}",
1366                self.risk_threshold
1367            ))
1368        } else {
1369            GovernanceDecision::Permit
1370        };
1371
1372        GovernanceResult {
1373            decision,
1374            evaluated_rules,
1375            effect: request.effect.clone(),
1376            threshold_exceeded,
1377            principal: Some(attributed),
1378        }
1379    }
1380
1381    /// Active browser_policy rules only.
1382    pub fn browser_policy_rules(&self) -> Vec<&GovernanceRule> {
1383        self.rules
1384            .iter()
1385            .filter(|r| r.active && r.rule_type == GovernanceRuleType::BrowserPolicy)
1386            .collect()
1387    }
1388
1389    /// Active binding-thread integrity rules only (WEFT-342).
1390    pub fn binding_thread_rules(&self) -> Vec<&GovernanceRule> {
1391        self.rules
1392            .iter()
1393            .filter(|r| r.active && r.rule_type == GovernanceRuleType::BindingThread)
1394            .collect()
1395    }
1396
1397    /// Get the configured risk threshold.
1398    pub fn risk_threshold(&self) -> f64 {
1399        self.risk_threshold
1400    }
1401
1402    /// Get total rule count.
1403    pub fn rule_count(&self) -> usize {
1404        self.rules.len()
1405    }
1406
1407    /// Evaluate a governance request in the context of a specific environment.
1408    ///
1409    /// Different environment classes apply different risk thresholds:
1410    /// - **Development**: uses the environment's own `risk_threshold` (lenient, typically 0.9).
1411    /// - **Staging**: uses the environment's own `risk_threshold` (moderate, typically 0.6).
1412    /// - **Production**: uses half the environment's `risk_threshold` (strict, typically 0.15).
1413    /// - **Custom**: uses the custom class's `risk_threshold` directly.
1414    ///
1415    /// After normal rule evaluation, an additional effect-magnitude check is
1416    /// performed against the environment-adjusted threshold. If the magnitude
1417    /// exceeds it, the decision is overridden to `Deny`.
1418    pub fn evaluate_in_environment(
1419        &self,
1420        request: &GovernanceRequest,
1421        env: &Environment,
1422    ) -> GovernanceResult {
1423        let adjusted_threshold = match &env.class {
1424            EnvironmentClass::Development => {
1425                // Dev: use the environment's risk threshold directly (lenient).
1426                env.governance.risk_threshold
1427            }
1428            EnvironmentClass::Staging => {
1429                // Staging: use the environment's risk threshold as-is.
1430                env.governance.risk_threshold
1431            }
1432            EnvironmentClass::Production => {
1433                // Production: halve the threshold for stricter gating.
1434                env.governance.risk_threshold * 0.5
1435            }
1436            EnvironmentClass::Custom { risk_threshold, .. } => {
1437                // Custom: use the class-level threshold.
1438                *risk_threshold
1439            }
1440        };
1441
1442        // Run normal rule-based evaluation first.
1443        let mut result = self.evaluate(request);
1444
1445        // Apply environment-scoped magnitude check on top.
1446        //
1447        // NOTE(eml-swap): wired — Finding #5 (GovernanceScorerModel).
1448        #[cfg(feature = "ecc")]
1449        let magnitude = request.effect.score(self.scorer.as_ref());
1450        #[cfg(not(feature = "ecc"))]
1451        let magnitude = request.effect.score();
1452        if magnitude > adjusted_threshold {
1453            result.threshold_exceeded = true;
1454            result.decision = GovernanceDecision::Deny(format!(
1455                "effect magnitude {magnitude:.2} exceeds {} environment threshold {adjusted_threshold:.2}",
1456                env.class,
1457            ));
1458        }
1459
1460        result
1461    }
1462
1463    /// Evaluate a governance request and log the decision to the chain.
1464    ///
1465    /// This is the recommended entry point when a `ChainManager` is
1466    /// available. It calls [`evaluate`](Self::evaluate) and records an
1467    /// `ipc.dead_letter`-style audit event via [`ChainLoggable`].
1468    ///
1469    /// If no chain manager is provided, behaves identically to `evaluate`.
1470    #[cfg(feature = "exochain")]
1471    pub fn evaluate_logged(
1472        &self,
1473        request: &GovernanceRequest,
1474        chain: Option<&crate::chain::ChainManager>,
1475    ) -> GovernanceResult {
1476        let result = self.evaluate(request);
1477        if let Some(cm) = chain {
1478            Self::chain_log_result(cm, request, &result);
1479        }
1480        result
1481    }
1482
1483    /// Evaluate in an environment and log the decision to the chain.
1484    #[cfg(feature = "exochain")]
1485    pub fn evaluate_in_environment_logged(
1486        &self,
1487        request: &GovernanceRequest,
1488        env: &Environment,
1489        chain: Option<&crate::chain::ChainManager>,
1490    ) -> GovernanceResult {
1491        let result = self.evaluate_in_environment(request, env);
1492        if let Some(cm) = chain {
1493            Self::chain_log_result(cm, request, &result);
1494        }
1495        result
1496    }
1497
1498    /// Log a governance result to the ExoChain.
1499    ///
1500    /// Can be called after any `evaluate` / `evaluate_in_environment`
1501    /// call to record the decision in the audit trail.
1502    #[cfg(feature = "exochain")]
1503    pub fn chain_log_result(
1504        cm: &crate::chain::ChainManager,
1505        request: &GovernanceRequest,
1506        result: &GovernanceResult,
1507    ) {
1508        use crate::chain::GovernanceDecisionEvent;
1509
1510        let decision_str = match &result.decision {
1511            GovernanceDecision::Permit => "Permit".to_owned(),
1512            GovernanceDecision::PermitWithWarning(_) => "PermitWithWarning".to_owned(),
1513            GovernanceDecision::EscalateToHuman(_) => "EscalateToHuman".to_owned(),
1514            GovernanceDecision::Deny(_) => "Deny".to_owned(),
1515        };
1516
1517        let principal = result
1518            .principal
1519            .clone()
1520            .unwrap_or_else(|| request.resolved_principal());
1521        let event = GovernanceDecisionEvent {
1522            agent_id: request.agent_id.clone(),
1523            action: request.action.clone(),
1524            decision: decision_str,
1525            effect_magnitude: request.effect.magnitude(),
1526            threshold_exceeded: result.threshold_exceeded,
1527            evaluated_rules: result.evaluated_rules.clone(),
1528            timestamp: chrono::Utc::now(),
1529            principal: Some(principal),
1530        };
1531        cm.append_loggable(&event);
1532    }
1533}
1534
1535// ── Trajectory recording ─────────────────────────────────────
1536//
1537// Records agent decision points for learning, replay, and
1538// pattern extraction. Lives alongside governance because every
1539// trajectory point is a governed decision.
1540
1541/// Outcome of an agent decision for trajectory scoring.
1542#[non_exhaustive]
1543#[derive(Debug, Clone, Serialize, Deserialize)]
1544pub enum TrajectoryOutcome {
1545    /// Decision succeeded with a reward signal.
1546    Success {
1547        /// Reward value (higher is better).
1548        reward: f64,
1549    },
1550    /// Decision failed.
1551    Failure {
1552        /// Reason for failure.
1553        reason: String,
1554    },
1555    /// Outcome not yet known.
1556    Pending,
1557}
1558
1559/// A record of an agent's decision for learning and replay.
1560#[derive(Debug, Clone, Serialize, Deserialize)]
1561pub struct TrajectoryRecord {
1562    /// Agent that made the decision.
1563    pub agent_id: String,
1564    /// What was decided (action name / tool call).
1565    pub action: String,
1566    /// Context at decision time (state snapshot).
1567    pub context: serde_json::Value,
1568    /// Outcome (success / failure / pending).
1569    pub outcome: TrajectoryOutcome,
1570    /// Timestamp of the decision.
1571    pub timestamp: chrono::DateTime<chrono::Utc>,
1572}
1573
1574/// Records agent trajectories for learning and pattern extraction.
1575///
1576/// Maintains a bounded FIFO buffer of [`TrajectoryRecord`]s. When the
1577/// buffer is full the oldest record is evicted. Callers can query by
1578/// agent and extract frequency patterns from successful actions.
1579pub struct TrajectoryRecorder {
1580    records: Vec<TrajectoryRecord>,
1581    max_records: usize,
1582}
1583
1584impl TrajectoryRecorder {
1585    /// Create a recorder with the given capacity.
1586    pub fn new(max_records: usize) -> Self {
1587        Self {
1588            records: Vec::new(),
1589            max_records,
1590        }
1591    }
1592
1593    /// Record a trajectory point. Evicts the oldest record on overflow.
1594    pub fn record(&mut self, record: TrajectoryRecord) {
1595        if self.records.len() >= self.max_records {
1596            self.records.remove(0); // FIFO eviction
1597        }
1598        self.records.push(record);
1599    }
1600
1601    /// Get all records for a specific agent.
1602    pub fn agent_trajectory(&self, agent_id: &str) -> Vec<&TrajectoryRecord> {
1603        self.records
1604            .iter()
1605            .filter(|r| r.agent_id == agent_id)
1606            .collect()
1607    }
1608
1609    /// Extract patterns: returns `(action, count)` pairs for successful
1610    /// actions, sorted by frequency descending.
1611    pub fn extract_patterns(&self) -> Vec<(String, usize)> {
1612        let mut action_counts: HashMap<String, usize> = HashMap::new();
1613        for record in &self.records {
1614            if matches!(record.outcome, TrajectoryOutcome::Success { .. }) {
1615                *action_counts.entry(record.action.clone()).or_default() += 1;
1616            }
1617        }
1618        let mut patterns: Vec<_> = action_counts.into_iter().collect();
1619        patterns.sort_by_key(|b| std::cmp::Reverse(b.1));
1620        patterns
1621    }
1622
1623    /// Number of recorded trajectories.
1624    pub fn len(&self) -> usize {
1625        self.records.len()
1626    }
1627
1628    /// Whether the recorder is empty.
1629    pub fn is_empty(&self) -> bool {
1630        self.records.is_empty()
1631    }
1632}
1633
1634// ── RVF governance bridge ────────────────────────────────────
1635//
1636// Behind `exochain` feature gate: bidirectional mapping between
1637// WeftOS constitutional governance and RVF witness governance.
1638//
1639// WeftOS governance evaluates *whether* an action should proceed
1640// (effect algebra, risk thresholds, branch-based rules).
1641// RVF governance records *what happened* during execution
1642// (witness bundles, tool call traces, cost budgets).
1643
1644#[cfg(feature = "exochain")]
1645impl GovernanceDecision {
1646    /// Map this decision to the equivalent RVF PolicyCheck.
1647    ///
1648    /// - `Permit` → `Allowed`
1649    /// - `PermitWithWarning` / `EscalateToHuman` → `Confirmed`
1650    /// - `Deny` → `Denied`
1651    pub fn to_rvf_policy_check(&self) -> rvf_types::witness::PolicyCheck {
1652        match self {
1653            GovernanceDecision::Permit => rvf_types::witness::PolicyCheck::Allowed,
1654            GovernanceDecision::PermitWithWarning(_) => rvf_types::witness::PolicyCheck::Confirmed,
1655            GovernanceDecision::EscalateToHuman(_) => rvf_types::witness::PolicyCheck::Confirmed,
1656            GovernanceDecision::Deny(_) => rvf_types::witness::PolicyCheck::Denied,
1657        }
1658    }
1659}
1660
1661#[cfg(feature = "exochain")]
1662impl GovernanceEngine {
1663    /// Derive the equivalent RVF GovernanceMode from this engine's config.
1664    ///
1665    /// - `risk_threshold >= 1.0` (open) → `Autonomous`
1666    /// - `human_approval_required` → `Approved`
1667    /// - otherwise → `Restricted`
1668    pub fn to_rvf_mode(&self) -> rvf_types::witness::GovernanceMode {
1669        if self.risk_threshold >= 1.0 {
1670            rvf_types::witness::GovernanceMode::Autonomous
1671        } else if self.human_approval_required {
1672            rvf_types::witness::GovernanceMode::Approved
1673        } else {
1674            rvf_types::witness::GovernanceMode::Restricted
1675        }
1676    }
1677
1678    /// Build an RVF GovernancePolicy from this engine's configuration.
1679    ///
1680    /// Uses the default tool lists and cost budgets for each mode.
1681    /// Callers can customize the returned policy further if needed.
1682    pub fn to_rvf_policy(&self) -> rvf_runtime::GovernancePolicy {
1683        match self.to_rvf_mode() {
1684            rvf_types::witness::GovernanceMode::Restricted => {
1685                rvf_runtime::GovernancePolicy::restricted()
1686            }
1687            rvf_types::witness::GovernanceMode::Approved => {
1688                rvf_runtime::GovernancePolicy::approved()
1689            }
1690            rvf_types::witness::GovernanceMode::Autonomous => {
1691                rvf_runtime::GovernancePolicy::autonomous()
1692            }
1693        }
1694    }
1695}
1696
1697#[cfg(feature = "exochain")]
1698impl GovernanceResult {
1699    /// Map the decision to an RVF TaskOutcome.
1700    ///
1701    /// This is a convenience for recording the governance result in a
1702    /// witness bundle. The caller should override based on actual execution.
1703    pub fn to_rvf_task_outcome(&self) -> rvf_types::witness::TaskOutcome {
1704        match &self.decision {
1705            GovernanceDecision::Permit | GovernanceDecision::PermitWithWarning(_) => {
1706                rvf_types::witness::TaskOutcome::Solved
1707            }
1708            GovernanceDecision::EscalateToHuman(_) => rvf_types::witness::TaskOutcome::Skipped,
1709            GovernanceDecision::Deny(_) => rvf_types::witness::TaskOutcome::Failed,
1710        }
1711    }
1712}
1713
1714#[cfg(test)]
1715mod tests {
1716    use super::*;
1717
1718    fn make_rule(id: &str, severity: RuleSeverity, branch: GovernanceBranch) -> GovernanceRule {
1719        GovernanceRule {
1720            id: id.into(),
1721            description: format!("Test rule {id}"),
1722            branch,
1723            severity,
1724            active: true,
1725            reference_url: None,
1726            sop_category: None,
1727            rule_type: Default::default(),
1728            action_selector: None,
1729            tool_selector: None,
1730            force_on_match: false,
1731        }
1732    }
1733
1734    #[test]
1735    fn effect_vector_magnitude() {
1736        let v = EffectVector {
1737            risk: 0.3,
1738            fairness: 0.4,
1739            privacy: 0.0,
1740            novelty: 0.0,
1741            security: 0.0,
1742        };
1743        assert!((v.magnitude() - 0.5).abs() < 0.001);
1744    }
1745
1746    #[test]
1747    fn effect_vector_zero() {
1748        let v = EffectVector::default();
1749        assert!((v.magnitude() - 0.0).abs() < f64::EPSILON);
1750    }
1751
1752    #[test]
1753    #[cfg(feature = "ecc")]
1754    fn effect_score_untrained_matches_magnitude() {
1755        // Finding #5: untrained scorer must be bit-for-bit identical to
1756        // the L2 magnitude. Covers both None and Some(untrained) paths.
1757        let v = EffectVector {
1758            risk: 0.5,
1759            fairness: 0.3,
1760            privacy: 0.2,
1761            novelty: 0.1,
1762            security: 0.4,
1763        };
1764
1765        let expected = v.magnitude();
1766        assert_eq!(v.score(None), expected);
1767
1768        let untrained = crate::eml_kernel::GovernanceScorerModel::new();
1769        assert!(!untrained.is_trained());
1770        assert_eq!(v.score(Some(&untrained)), expected);
1771    }
1772
1773    #[test]
1774    #[cfg(feature = "ecc")]
1775    fn effect_score_trained_uses_model() {
1776        // With a trained scorer, score() takes the EML path instead of
1777        // L2. We force the trained flag via a JSON patch because the
1778        // underlying coordinate descent needs 50+ samples to declare
1779        // convergence, which would make this a slow integration test.
1780        let v = EffectVector {
1781            risk: 1.0,
1782            fairness: 1.0,
1783            privacy: 1.0,
1784            novelty: 1.0,
1785            security: 1.0,
1786        };
1787
1788        let scorer = crate::eml_kernel::GovernanceScorerModel::new();
1789        let untrained_score = v.score(Some(&scorer));
1790        assert_eq!(untrained_score, v.magnitude());
1791
1792        // Patch the serialized form to force is_trained() = true.
1793        let mut json = serde_json::to_value(&scorer).unwrap();
1794        if let Some(inner) = json.get_mut("inner").and_then(|v| v.as_object_mut()) {
1795            inner.insert("trained".into(), serde_json::Value::Bool(true));
1796        }
1797        let forced: crate::eml_kernel::GovernanceScorerModel =
1798            serde_json::from_value(json).expect("patched scorer must deserialize");
1799        assert!(forced.is_trained(), "patched scorer should report trained");
1800
1801        // Fall-through branch runs; we don't assert a specific value
1802        // (params are zeros, so the EML output is deterministic but
1803        // implementation-defined). The contract is: `score` dispatches
1804        // to the model instead of the L2 shortcut.
1805        let trained_score = v.score(Some(&forced));
1806        // Untrained L2 for (1,1,1,1,1) is sqrt(5) ≈ 2.236. The model
1807        // predict_primary over zero-params is 0.0 (clamped at 0.0 by
1808        // max(0.0)). These must differ — proves the branch taken.
1809        assert_ne!(
1810            trained_score, untrained_score,
1811            "trained scorer must dispatch to the model, not L2"
1812        );
1813    }
1814
1815    #[test]
1816    fn effect_any_exceeds() {
1817        let v = EffectVector {
1818            risk: 0.8,
1819            ..Default::default()
1820        };
1821        assert!(v.any_exceeds(0.5));
1822        assert!(!v.any_exceeds(0.9));
1823    }
1824
1825    #[test]
1826    fn effect_max_dimension() {
1827        let v = EffectVector {
1828            risk: 0.2,
1829            fairness: 0.5,
1830            privacy: 0.3,
1831            novelty: 0.1,
1832            security: 0.4,
1833        };
1834        assert!((v.max_dimension() - 0.5).abs() < f64::EPSILON);
1835    }
1836
1837    #[test]
1838    fn governance_branch_display() {
1839        assert_eq!(GovernanceBranch::Legislative.to_string(), "legislative");
1840        assert_eq!(GovernanceBranch::Executive.to_string(), "executive");
1841        assert_eq!(GovernanceBranch::Judicial.to_string(), "judicial");
1842    }
1843
1844    #[test]
1845    fn rule_severity_ordering() {
1846        assert!(RuleSeverity::Advisory < RuleSeverity::Warning);
1847        assert!(RuleSeverity::Warning < RuleSeverity::Blocking);
1848        assert!(RuleSeverity::Blocking < RuleSeverity::Critical);
1849    }
1850
1851    #[test]
1852    fn governance_decision_display() {
1853        assert_eq!(GovernanceDecision::Permit.to_string(), "permit");
1854        assert!(
1855            GovernanceDecision::Deny("too risky".into())
1856                .to_string()
1857                .contains("too risky")
1858        );
1859    }
1860
1861    #[test]
1862    fn open_engine_permits_everything() {
1863        let engine = GovernanceEngine::open();
1864        let request = GovernanceRequest {
1865            agent_id: "agent-1".into(),
1866            action: "deploy".into(),
1867            effect: EffectVector {
1868                risk: 0.9,
1869                security: 0.9,
1870                ..Default::default()
1871            },
1872            context: Default::default(),
1873            node_id: None,
1874            principal: None,
1875        };
1876        let result = engine.evaluate(&request);
1877        assert_eq!(result.decision, GovernanceDecision::Permit);
1878    }
1879
1880    #[test]
1881    fn eval_rate_limit_blocks_per_principal() {
1882        // WEFT-148: 2 evaluations per principal, then deny.
1883        let engine = GovernanceEngine::open().with_eval_rate_limit(
1884            crate::rate_limit::RateLimitConfig {
1885                max_per_window: 2,
1886                window: std::time::Duration::from_secs(60),
1887            },
1888        );
1889        let req = GovernanceRequest::new("agent-flood", "noop");
1890        assert_eq!(engine.evaluate(&req).decision, GovernanceDecision::Permit);
1891        assert_eq!(engine.evaluate(&req).decision, GovernanceDecision::Permit);
1892        let limited = engine.evaluate(&req);
1893        match limited.decision {
1894            GovernanceDecision::Deny(ref msg) => {
1895                assert!(
1896                    msg.contains("rate limited"),
1897                    "expected rate-limited deny, got {msg}"
1898                );
1899            }
1900            other => panic!("expected Deny(rate limited), got {other:?}"),
1901        }
1902        assert!(limited.evaluated_rules.is_empty());
1903    }
1904
1905    #[test]
1906    fn eval_rate_limit_is_per_principal() {
1907        let engine = GovernanceEngine::open().with_eval_rate_limit(
1908            crate::rate_limit::RateLimitConfig {
1909                max_per_window: 1,
1910                window: std::time::Duration::from_secs(60),
1911            },
1912        );
1913        let a = GovernanceRequest::new("agent-a", "noop");
1914        let b = GovernanceRequest::new("agent-b", "noop");
1915        assert_eq!(engine.evaluate(&a).decision, GovernanceDecision::Permit);
1916        // Same principal blocked.
1917        assert!(matches!(
1918            engine.evaluate(&a).decision,
1919            GovernanceDecision::Deny(_)
1920        ));
1921        // Different principal still allowed.
1922        assert_eq!(engine.evaluate(&b).decision, GovernanceDecision::Permit);
1923    }
1924
1925    #[test]
1926    fn eval_rate_limit_configurable_and_disableable() {
1927        let engine = GovernanceEngine::new(0.5, false).with_eval_rate_limit(
1928            crate::rate_limit::RateLimitConfig::unlimited(),
1929        );
1930        assert!(!engine.eval_rate_limit().is_enabled());
1931        let req = GovernanceRequest::new("agent-1", "noop");
1932        for _ in 0..20 {
1933            assert_eq!(engine.evaluate(&req).decision, GovernanceDecision::Permit);
1934        }
1935    }
1936
1937    #[test]
1938    fn eval_principal_key_prefers_agent_id() {
1939        let mut req = GovernanceRequest::new("agent-x", "act");
1940        req = req.with_node_id("node-y");
1941        assert_eq!(GovernanceEngine::eval_principal_key(&req), "agent-x");
1942        let mut empty_agent = GovernanceRequest::new("", "act");
1943        empty_agent.node_id = Some("node-y".into());
1944        assert_eq!(
1945            GovernanceEngine::eval_principal_key(&empty_agent),
1946            "node-y"
1947        );
1948    }
1949
1950    #[test]
1951    fn blocking_rule_denies() {
1952        let mut engine = GovernanceEngine::new(0.5, false);
1953        engine.add_rule(make_rule(
1954            "security-check",
1955            RuleSeverity::Blocking,
1956            GovernanceBranch::Judicial,
1957        ));
1958
1959        let request = GovernanceRequest {
1960            agent_id: "agent-1".into(),
1961            action: "deploy".into(),
1962            effect: EffectVector {
1963                risk: 0.6,
1964                ..Default::default()
1965            },
1966            context: Default::default(),
1967            node_id: None,
1968            principal: None,
1969        };
1970        let result = engine.evaluate(&request);
1971        assert!(matches!(result.decision, GovernanceDecision::Deny(_)));
1972        assert!(result.threshold_exceeded);
1973    }
1974
1975    #[test]
1976    #[cfg(feature = "ecc")]
1977    fn engine_with_untrained_scorer_preserves_behavior() {
1978        // Finding #5: installing an untrained GovernanceScorerModel must
1979        // not change evaluation outcomes (drop-in safe).
1980        let mut baseline = GovernanceEngine::new(0.5, false);
1981        baseline.add_rule(make_rule(
1982            "security-check",
1983            RuleSeverity::Blocking,
1984            GovernanceBranch::Judicial,
1985        ));
1986        let mut wired = GovernanceEngine::new(0.5, false)
1987            .with_scorer(crate::eml_kernel::GovernanceScorerModel::new());
1988        wired.add_rule(make_rule(
1989            "security-check",
1990            RuleSeverity::Blocking,
1991            GovernanceBranch::Judicial,
1992        ));
1993
1994        for effect in [
1995            EffectVector {
1996                risk: 0.6,
1997                ..Default::default()
1998            },
1999            EffectVector {
2000                risk: 0.1,
2001                fairness: 0.1,
2002                ..Default::default()
2003            },
2004            EffectVector::default(),
2005        ] {
2006            let req = GovernanceRequest {
2007                agent_id: "a".into(),
2008                action: "x".into(),
2009                effect,
2010                context: Default::default(),
2011                node_id: None,
2012                principal: None,
2013            };
2014            assert_eq!(
2015                baseline.evaluate(&req).decision,
2016                wired.evaluate(&req).decision,
2017                "untrained scorer must reproduce L2 behaviour"
2018            );
2019        }
2020    }
2021
2022    #[test]
2023    #[cfg(feature = "ecc")]
2024    fn engine_with_trained_scorer_uses_model_scalar() {
2025        // Finding #5: once the scorer is trained, the engine's
2026        // threshold check receives the model-derived scalar. We force
2027        // `is_trained` via a JSON patch so we don't have to train.
2028        let scorer = crate::eml_kernel::GovernanceScorerModel::new();
2029        let mut json = serde_json::to_value(&scorer).unwrap();
2030        if let Some(inner) = json.get_mut("inner").and_then(|v| v.as_object_mut()) {
2031            inner.insert("trained".into(), serde_json::Value::Bool(true));
2032        }
2033        let forced: crate::eml_kernel::GovernanceScorerModel =
2034            serde_json::from_value(json).unwrap();
2035        assert!(forced.is_trained());
2036
2037        let effect = EffectVector {
2038            risk: 1.0,
2039            ..Default::default()
2040        };
2041        let l2 = effect.magnitude();
2042        let model_scalar = effect.score(Some(&forced));
2043        // Zero params + softmax3(0,0,0) means model_scalar is derived
2044        // from the EML tree and NOT equal to the L2 magnitude — this
2045        // is the core invariant: the trained branch actually fires.
2046        assert_ne!(
2047            model_scalar, l2,
2048            "trained scorer must not return L2 magnitude"
2049        );
2050
2051        // Bonus: verify the engine uses it by picking a threshold that
2052        // straddles the two values. Because we don't know which scalar
2053        // is larger, pick between them.
2054        let (lo, hi) = if model_scalar < l2 {
2055            (model_scalar, l2)
2056        } else {
2057            (l2, model_scalar)
2058        };
2059        let mid = (lo + hi) / 2.0;
2060        let engine = GovernanceEngine::new(mid, false).with_scorer(forced);
2061        let request = GovernanceRequest {
2062            agent_id: "a".into(),
2063            action: "x".into(),
2064            effect,
2065            context: Default::default(),
2066            node_id: None,
2067            principal: None,
2068        };
2069        let result = engine.evaluate(&request);
2070        // With threshold between model_scalar and l2: outcome depends
2071        // on which is larger. The invariant we check is that the
2072        // scorer-backed decision uses `model_scalar` (> mid only if
2073        // `model_scalar == hi`).
2074        assert_eq!(result.threshold_exceeded, model_scalar > mid);
2075    }
2076
2077    #[test]
2078    fn blocking_with_human_approval_escalates() {
2079        let mut engine = GovernanceEngine::new(0.5, true);
2080        engine.add_rule(make_rule(
2081            "security-check",
2082            RuleSeverity::Blocking,
2083            GovernanceBranch::Judicial,
2084        ));
2085
2086        let request = GovernanceRequest {
2087            agent_id: "agent-1".into(),
2088            action: "deploy".into(),
2089            effect: EffectVector {
2090                risk: 0.6,
2091                ..Default::default()
2092            },
2093            context: Default::default(),
2094            node_id: None,
2095            principal: None,
2096        };
2097        let result = engine.evaluate(&request);
2098        assert!(matches!(
2099            result.decision,
2100            GovernanceDecision::EscalateToHuman(_)
2101        ));
2102    }
2103
2104    #[test]
2105    fn warning_rule_permits_with_warning() {
2106        let mut engine = GovernanceEngine::new(0.5, false);
2107        engine.add_rule(make_rule(
2108            "risk-check",
2109            RuleSeverity::Warning,
2110            GovernanceBranch::Executive,
2111        ));
2112
2113        let request = GovernanceRequest {
2114            agent_id: "agent-1".into(),
2115            action: "deploy".into(),
2116            effect: EffectVector {
2117                risk: 0.6,
2118                ..Default::default()
2119            },
2120            context: Default::default(),
2121            node_id: None,
2122            principal: None,
2123        };
2124        let result = engine.evaluate(&request);
2125        assert!(matches!(
2126            result.decision,
2127            GovernanceDecision::PermitWithWarning(_)
2128        ));
2129    }
2130
2131    #[test]
2132    fn below_threshold_permits() {
2133        let mut engine = GovernanceEngine::new(0.5, false);
2134        engine.add_rule(make_rule(
2135            "security-check",
2136            RuleSeverity::Blocking,
2137            GovernanceBranch::Judicial,
2138        ));
2139
2140        let request = GovernanceRequest {
2141            agent_id: "agent-1".into(),
2142            action: "read".into(),
2143            effect: EffectVector {
2144                risk: 0.1,
2145                ..Default::default()
2146            },
2147            context: Default::default(),
2148            node_id: None,
2149            principal: None,
2150        };
2151        let result = engine.evaluate(&request);
2152        assert_eq!(result.decision, GovernanceDecision::Permit);
2153        assert!(!result.threshold_exceeded);
2154    }
2155
2156    #[test]
2157    fn rules_by_branch() {
2158        let mut engine = GovernanceEngine::new(0.5, false);
2159        engine.add_rule(make_rule(
2160            "r1",
2161            RuleSeverity::Warning,
2162            GovernanceBranch::Legislative,
2163        ));
2164        engine.add_rule(make_rule(
2165            "r2",
2166            RuleSeverity::Blocking,
2167            GovernanceBranch::Judicial,
2168        ));
2169        engine.add_rule(make_rule(
2170            "r3",
2171            RuleSeverity::Advisory,
2172            GovernanceBranch::Judicial,
2173        ));
2174
2175        let judicial = engine.rules_by_branch(&GovernanceBranch::Judicial);
2176        assert_eq!(judicial.len(), 2);
2177        let legislative = engine.rules_by_branch(&GovernanceBranch::Legislative);
2178        assert_eq!(legislative.len(), 1);
2179    }
2180
2181    #[test]
2182    fn inactive_rules_excluded() {
2183        let mut engine = GovernanceEngine::new(0.5, false);
2184        engine.add_rule(GovernanceRule {
2185            id: "disabled".into(),
2186            description: "Disabled rule".into(),
2187            branch: GovernanceBranch::Judicial,
2188            severity: RuleSeverity::Blocking,
2189            active: false,
2190            reference_url: None,
2191            sop_category: None,
2192            rule_type: Default::default(),
2193            action_selector: None,
2194            tool_selector: None,
2195            force_on_match: false,
2196        });
2197        assert_eq!(engine.active_rules().len(), 0);
2198    }
2199
2200    #[test]
2201    fn governance_rule_serde_roundtrip() {
2202        let rule = make_rule("sec-1", RuleSeverity::Critical, GovernanceBranch::Judicial);
2203        let json = serde_json::to_string(&rule).unwrap();
2204        let restored: GovernanceRule = serde_json::from_str(&json).unwrap();
2205        assert_eq!(restored.id, "sec-1");
2206        assert!(restored.active);
2207    }
2208
2209    #[test]
2210    fn governance_request_serde_roundtrip() {
2211        let request = GovernanceRequest {
2212            agent_id: "agent-1".into(),
2213            action: "deploy".into(),
2214            effect: EffectVector {
2215                risk: 0.5,
2216                privacy: 0.3,
2217                ..Default::default()
2218            },
2219            context: std::collections::HashMap::from([("env".into(), "prod".into())]),
2220            node_id: None,
2221            principal: None,
2222        };
2223        let json = serde_json::to_string(&request).unwrap();
2224        let restored: GovernanceRequest = serde_json::from_str(&json).unwrap();
2225        assert_eq!(restored.agent_id, "agent-1");
2226        assert!((restored.effect.risk - 0.5).abs() < f64::EPSILON);
2227        assert!(restored.node_id.is_none());
2228    }
2229
2230    #[test]
2231    fn governance_request_with_node_id() {
2232        let request = GovernanceRequest::new("agent-1", "deploy")
2233            .with_node_id("node-42")
2234            .with_effect(EffectVector {
2235                risk: 0.1,
2236                ..Default::default()
2237            });
2238
2239        assert_eq!(request.node_id.as_deref(), Some("node-42"));
2240        assert_eq!(request.agent_id, "agent-1");
2241
2242        // Serde roundtrip preserves node_id.
2243        let json = serde_json::to_string(&request).unwrap();
2244        assert!(json.contains("node-42"));
2245        let restored: GovernanceRequest = serde_json::from_str(&json).unwrap();
2246        assert_eq!(restored.node_id.as_deref(), Some("node-42"));
2247    }
2248
2249    #[test]
2250    fn governance_request_without_node_id_deserializes() {
2251        // JSON without node_id should deserialize with node_id = None (backward compat).
2252        let json = r#"{"agent_id":"a","action":"b","effect":{},"context":{}}"#;
2253        let request: GovernanceRequest = serde_json::from_str(json).unwrap();
2254        assert!(request.node_id.is_none());
2255    }
2256
2257    #[test]
2258    fn governance_request_builder() {
2259        let request = GovernanceRequest::new("agent-1", "deploy");
2260        assert_eq!(request.agent_id, "agent-1");
2261        assert_eq!(request.action, "deploy");
2262        assert!(request.node_id.is_none());
2263        assert!((request.effect.magnitude() - 0.0).abs() < f64::EPSILON);
2264    }
2265
2266    #[test]
2267    fn effect_vector_serde_roundtrip() {
2268        let v = EffectVector {
2269            risk: 0.1,
2270            fairness: 0.2,
2271            privacy: 0.3,
2272            novelty: 0.4,
2273            security: 0.5,
2274        };
2275        let json = serde_json::to_string(&v).unwrap();
2276        let restored: EffectVector = serde_json::from_str(&json).unwrap();
2277        assert!((restored.security - 0.5).abs() < f64::EPSILON);
2278    }
2279
2280    #[test]
2281    fn filter_rules_by_sop_category() {
2282        let rules = vec![
2283            GovernanceRule {
2284                id: "SOP-L001".into(),
2285                description: "test".into(),
2286                branch: GovernanceBranch::Legislative,
2287                severity: RuleSeverity::Blocking,
2288                active: true,
2289                reference_url: Some("https://example.com".into()),
2290                sop_category: Some("governance".into()),
2291                rule_type: Default::default(),
2292                action_selector: None,
2293                tool_selector: None,
2294                force_on_match: false,
2295            },
2296            GovernanceRule {
2297                id: "SOP-J001".into(),
2298                description: "test".into(),
2299                branch: GovernanceBranch::Judicial,
2300                severity: RuleSeverity::Blocking,
2301                active: true,
2302                reference_url: Some("https://example.com".into()),
2303                sop_category: Some("ethics".into()),
2304                rule_type: Default::default(),
2305                action_selector: None,
2306                tool_selector: None,
2307                force_on_match: false,
2308            },
2309            GovernanceRule {
2310                id: "GOV-001".into(),
2311                description: "test".into(),
2312                branch: GovernanceBranch::Judicial,
2313                severity: RuleSeverity::Blocking,
2314                active: true,
2315                reference_url: None,
2316                sop_category: None,
2317                rule_type: Default::default(),
2318                action_selector: None,
2319                tool_selector: None,
2320                force_on_match: false,
2321            },
2322        ];
2323        let ethics = GovernanceRule::filter_by_category(&rules, "ethics");
2324        assert_eq!(ethics.len(), 1);
2325        assert_eq!(ethics[0].id, "SOP-J001");
2326
2327        let governance = GovernanceRule::filter_by_category(&rules, "governance");
2328        assert_eq!(governance.len(), 1);
2329
2330        let none = GovernanceRule::filter_by_category(&rules, "nonexistent");
2331        assert!(none.is_empty());
2332    }
2333
2334    #[test]
2335    fn governance_rule_with_sop_serde() {
2336        let rule = GovernanceRule {
2337            id: "SOP-L001".into(),
2338            description: "test".into(),
2339            branch: GovernanceBranch::Legislative,
2340            severity: RuleSeverity::Blocking,
2341            active: true,
2342            reference_url: Some("https://example.com/sop".into()),
2343            sop_category: Some("governance".into()),
2344            rule_type: Default::default(),
2345            action_selector: None,
2346            tool_selector: None,
2347            force_on_match: false,
2348        };
2349        let json = serde_json::to_string(&rule).unwrap();
2350        assert!(json.contains("reference_url"));
2351        assert!(json.contains("sop_category"));
2352        let restored: GovernanceRule = serde_json::from_str(&json).unwrap();
2353        assert_eq!(
2354            restored.reference_url,
2355            Some("https://example.com/sop".into())
2356        );
2357    }
2358
2359    #[test]
2360    fn governance_rule_without_sop_backward_compat() {
2361        // Old-format JSON without new fields should deserialize fine
2362        let json = r#"{"id":"GOV-001","description":"test","branch":"Judicial","severity":"Blocking","active":true}"#;
2363        let rule: GovernanceRule = serde_json::from_str(json).unwrap();
2364        assert!(rule.reference_url.is_none());
2365        assert!(rule.sop_category.is_none());
2366        assert_eq!(rule.rule_type, GovernanceRuleType::General);
2367    }
2368
2369    // ── Browser policy (S7 / WEFT-108) ────────────────────────────────
2370
2371    #[test]
2372    fn browser_policy_default_rules_shape() {
2373        let rules = browser_policy_default_rules();
2374        assert_eq!(rules.len(), 3);
2375        for rule in &rules {
2376            assert_eq!(rule.rule_type, GovernanceRuleType::BrowserPolicy);
2377            assert_eq!(rule.sop_category.as_deref(), Some("browser_policy"));
2378            assert_eq!(rule.severity, RuleSeverity::Blocking);
2379            assert!(rule.active);
2380        }
2381        let ids: Vec<_> = rules.iter().map(|r| r.id.as_str()).collect();
2382        assert_eq!(ids, vec!["BP-001", "BP-002", "BP-003"]);
2383    }
2384
2385    #[test]
2386    fn browser_policy_denies_non_public_topic_for_browser() {
2387        let mut engine = GovernanceEngine::new(0.7, false);
2388        for rule in browser_policy_default_rules() {
2389            engine.add_rule(rule);
2390        }
2391
2392        let request = GovernanceRequest::new("browser-agent", "ipc.topic.subscribe")
2393            .with_context_entry("platform", "browser")
2394            .with_context_entry("topic", "admin.secrets");
2395
2396        let result = engine.evaluate(&request);
2397        assert!(
2398            matches!(result.decision, GovernanceDecision::Deny(ref r) if r.contains("public")),
2399            "browser non-public topic must be denied, got {:?}",
2400            result.decision
2401        );
2402    }
2403
2404    #[test]
2405    fn browser_policy_allows_public_topic_for_browser() {
2406        let mut engine = GovernanceEngine::new(0.7, false);
2407        for rule in browser_policy_default_rules() {
2408            engine.add_rule(rule);
2409        }
2410
2411        let request = GovernanceRequest::new("browser-agent", "ipc.topic.subscribe")
2412            .with_context_entry("platform", "browser")
2413            .with_context_entry("topic", "public.events");
2414
2415        let result = engine.evaluate(&request);
2416        assert_eq!(
2417            result.decision,
2418            GovernanceDecision::Permit,
2419            "browser public topic must be permitted, got {:?}",
2420            result.decision
2421        );
2422    }
2423
2424    #[test]
2425    fn browser_policy_does_not_apply_to_native_platform() {
2426        let mut engine = GovernanceEngine::new(0.7, false);
2427        for rule in browser_policy_default_rules() {
2428            engine.add_rule(rule);
2429        }
2430
2431        // Native platform may access non-public topics (browser_policy no-op).
2432        let request = GovernanceRequest::new("native-agent", "ipc.topic.subscribe")
2433            .with_context_entry("platform", "native")
2434            .with_context_entry("topic", "admin.secrets");
2435
2436        let result = engine.evaluate(&request);
2437        assert_eq!(result.decision, GovernanceDecision::Permit);
2438    }
2439
2440    #[test]
2441    fn browser_policy_denies_spawn_for_browser() {
2442        let mut engine = GovernanceEngine::new(0.7, false);
2443        for rule in browser_policy_default_rules() {
2444            engine.add_rule(rule);
2445        }
2446
2447        let request = GovernanceRequest::new("browser-agent", "agent.spawn")
2448            .with_context_entry("platform", "browser");
2449
2450        let result = engine.evaluate(&request);
2451        assert!(
2452            matches!(result.decision, GovernanceDecision::Deny(ref r) if r.contains("spawn")),
2453            "browser spawn must be denied, got {:?}",
2454            result.decision
2455        );
2456    }
2457
2458    #[test]
2459    fn filter_by_browser_policy_type() {
2460        let rules = browser_policy_default_rules();
2461        let filtered =
2462            GovernanceRule::filter_by_type(&rules, &GovernanceRuleType::BrowserPolicy);
2463        assert_eq!(filtered.len(), 3);
2464        let general = GovernanceRule::filter_by_type(&rules, &GovernanceRuleType::General);
2465        assert!(general.is_empty());
2466    }
2467
2468    // ── Genesis rule enforcement tests ──────────────────────────────
2469
2470    /// Helper to create a GovernanceRule with optional SOP category,
2471    /// matching the shape used in boot.rs genesis rules.
2472    fn make_sop_rule(
2473        id: &str,
2474        severity: RuleSeverity,
2475        branch: GovernanceBranch,
2476        category: Option<&str>,
2477    ) -> GovernanceRule {
2478        GovernanceRule {
2479            id: id.into(),
2480            description: format!("Genesis rule {id}"),
2481            branch,
2482            severity,
2483            active: true,
2484            reference_url: None,
2485            sop_category: category.map(|c| c.into()),
2486            rule_type: Default::default(),
2487            action_selector: None,
2488            tool_selector: None,
2489            force_on_match: false,
2490        }
2491    }
2492
2493    /// Build a GovernanceEngine with all genesis rules matching boot.rs
2494    /// (22 constitutional/SOP + 3 browser_policy + 1 binding_thread = 26).
2495    fn genesis_engine() -> GovernanceEngine {
2496        let mut engine = GovernanceEngine::new(0.7, false);
2497
2498        // ── Core constitutional rules (GOV-001 .. GOV-007) ──────
2499        // Judicial blocking
2500        engine.add_rule(make_sop_rule(
2501            "GOV-001",
2502            RuleSeverity::Blocking,
2503            GovernanceBranch::Judicial,
2504            None,
2505        ));
2506        engine.add_rule(make_sop_rule(
2507            "GOV-002",
2508            RuleSeverity::Blocking,
2509            GovernanceBranch::Judicial,
2510            None,
2511        ));
2512        // Legislative warning
2513        engine.add_rule(make_sop_rule(
2514            "GOV-003",
2515            RuleSeverity::Warning,
2516            GovernanceBranch::Legislative,
2517            None,
2518        ));
2519        // Executive advisory
2520        engine.add_rule(make_sop_rule(
2521            "GOV-004",
2522            RuleSeverity::Advisory,
2523            GovernanceBranch::Executive,
2524            None,
2525        ));
2526        // Legislative warning
2527        engine.add_rule(make_sop_rule(
2528            "GOV-005",
2529            RuleSeverity::Warning,
2530            GovernanceBranch::Legislative,
2531            None,
2532        ));
2533        // Executive blocking
2534        engine.add_rule(make_sop_rule(
2535            "GOV-006",
2536            RuleSeverity::Blocking,
2537            GovernanceBranch::Executive,
2538            None,
2539        ));
2540        // Judicial advisory
2541        engine.add_rule(make_sop_rule(
2542            "GOV-007",
2543            RuleSeverity::Advisory,
2544            GovernanceBranch::Judicial,
2545            None,
2546        ));
2547
2548        // ── AI-SDLC SOP rules: Legislative (6) ──────────────────
2549        engine.add_rule(make_sop_rule(
2550            "SOP-L001",
2551            RuleSeverity::Blocking,
2552            GovernanceBranch::Legislative,
2553            Some("governance"),
2554        ));
2555        engine.add_rule(make_sop_rule(
2556            "SOP-L002",
2557            RuleSeverity::Warning,
2558            GovernanceBranch::Legislative,
2559            Some("governance"),
2560        ));
2561        engine.add_rule(make_sop_rule(
2562            "SOP-L003",
2563            RuleSeverity::Warning,
2564            GovernanceBranch::Legislative,
2565            Some("engineering"),
2566        ));
2567        engine.add_rule(make_sop_rule(
2568            "SOP-L004",
2569            RuleSeverity::Advisory,
2570            GovernanceBranch::Legislative,
2571            Some("lifecycle"),
2572        ));
2573        engine.add_rule(make_sop_rule(
2574            "SOP-L005",
2575            RuleSeverity::Blocking,
2576            GovernanceBranch::Legislative,
2577            Some("ethics"),
2578        ));
2579        engine.add_rule(make_sop_rule(
2580            "SOP-L006",
2581            RuleSeverity::Warning,
2582            GovernanceBranch::Legislative,
2583            Some("governance"),
2584        ));
2585
2586        // ── AI-SDLC SOP rules: Executive (5) ────────────────────
2587        engine.add_rule(make_sop_rule(
2588            "SOP-E001",
2589            RuleSeverity::Warning,
2590            GovernanceBranch::Executive,
2591            Some("engineering"),
2592        ));
2593        engine.add_rule(make_sop_rule(
2594            "SOP-E002",
2595            RuleSeverity::Blocking,
2596            GovernanceBranch::Executive,
2597            Some("lifecycle"),
2598        ));
2599        engine.add_rule(make_sop_rule(
2600            "SOP-E003",
2601            RuleSeverity::Warning,
2602            GovernanceBranch::Executive,
2603            Some("security"),
2604        ));
2605        engine.add_rule(make_sop_rule(
2606            "SOP-E004",
2607            RuleSeverity::Advisory,
2608            GovernanceBranch::Executive,
2609            Some("lifecycle"),
2610        ));
2611        engine.add_rule(make_sop_rule(
2612            "SOP-E005",
2613            RuleSeverity::Advisory,
2614            GovernanceBranch::Executive,
2615            Some("governance"),
2616        ));
2617
2618        // ── AI-SDLC SOP rules: Judicial (4) ─────────────────────
2619        engine.add_rule(make_sop_rule(
2620            "SOP-J001",
2621            RuleSeverity::Blocking,
2622            GovernanceBranch::Judicial,
2623            Some("ethics"),
2624        ));
2625        engine.add_rule(make_sop_rule(
2626            "SOP-J002",
2627            RuleSeverity::Warning,
2628            GovernanceBranch::Judicial,
2629            Some("ethics"),
2630        ));
2631        engine.add_rule(make_sop_rule(
2632            "SOP-J003",
2633            RuleSeverity::Warning,
2634            GovernanceBranch::Judicial,
2635            Some("lifecycle"),
2636        ));
2637        engine.add_rule(make_sop_rule(
2638            "SOP-J004",
2639            RuleSeverity::Advisory,
2640            GovernanceBranch::Judicial,
2641            Some("quality"),
2642        ));
2643
2644        // ── Browser policy rules (S7 / WEFT-108) ────────────────
2645        for rule in browser_policy_default_rules() {
2646            engine.add_rule(rule);
2647        }
2648
2649        // ── Binding-thread integrity (WEFT-342) ─────────────────
2650        for rule in binding_thread_default_rules() {
2651            engine.add_rule(rule);
2652        }
2653
2654        engine
2655    }
2656
2657    #[test]
2658    fn genesis_has_26_rules() {
2659        let engine = genesis_engine();
2660        // 22 constitutional/SOP + 3 browser_policy + 1 binding_thread
2661        assert_eq!(engine.rule_count(), 26);
2662    }
2663
2664    #[test]
2665    fn binding_thread_rule_denies_mismatch() {
2666        let engine = genesis_engine();
2667        let request = GovernanceRequest {
2668            agent_id: "concierge".into(),
2669            action: BINDING_THREAD_RULE_ID.into(),
2670            effect: EffectVector {
2671                security: 1.0,
2672                risk: 0.9,
2673                ..Default::default()
2674            },
2675            context: std::collections::HashMap::from([(
2676                "binding_thread_status".into(),
2677                "mismatch".into(),
2678            )]),
2679            node_id: None,
2680            principal: None,
2681        };
2682        let result = engine.evaluate(&request);
2683        match result.decision {
2684            GovernanceDecision::Deny(reason) => {
2685                assert_eq!(reason, BINDING_THREAD_MISMATCH_REASON);
2686            }
2687            other => panic!("expected Deny(binding-thread mismatch), got {other:?}"),
2688        }
2689        assert!(
2690            result
2691                .evaluated_rules
2692                .iter()
2693                .any(|id| id == BINDING_THREAD_RULE_ID)
2694        );
2695    }
2696
2697    #[test]
2698    fn binding_thread_rule_permits_ok() {
2699        let engine = genesis_engine();
2700        let request = GovernanceRequest {
2701            agent_id: "concierge".into(),
2702            action: BINDING_THREAD_RULE_ID.into(),
2703            effect: EffectVector::default(),
2704            context: std::collections::HashMap::from([(
2705                "binding_thread_status".into(),
2706                "ok".into(),
2707            )]),
2708            node_id: None,
2709            principal: None,
2710        };
2711        let result = engine.evaluate(&request);
2712        assert!(
2713            matches!(result.decision, GovernanceDecision::Permit),
2714            "intact binding thread must Permit, got {:?}",
2715            result.decision
2716        );
2717    }
2718
2719    #[test]
2720    fn binding_thread_default_rules_shape() {
2721        let rules = binding_thread_default_rules();
2722        assert_eq!(rules.len(), 1);
2723        assert_eq!(rules[0].id, BINDING_THREAD_RULE_ID);
2724        assert_eq!(rules[0].rule_type, GovernanceRuleType::BindingThread);
2725        assert_eq!(rules[0].severity, RuleSeverity::Blocking);
2726        assert!(rules[0].active);
2727    }
2728
2729    #[test]
2730    fn genesis_high_risk_operation_blocked() {
2731        let engine = genesis_engine();
2732        let request = GovernanceRequest {
2733            agent_id: "agent-1".into(),
2734            action: "deploy-to-prod".into(),
2735            effect: EffectVector {
2736                risk: 0.9,
2737                ..Default::default()
2738            },
2739            context: Default::default(),
2740            node_id: None,
2741            principal: None,
2742        };
2743        let result = engine.evaluate(&request);
2744        assert!(
2745            matches!(result.decision, GovernanceDecision::Deny(_)),
2746            "high-risk operation should be denied, got {:?}",
2747            result.decision,
2748        );
2749        assert!(result.threshold_exceeded);
2750    }
2751
2752    #[test]
2753    fn genesis_low_risk_operation_permitted() {
2754        let engine = genesis_engine();
2755        let request = GovernanceRequest {
2756            agent_id: "agent-1".into(),
2757            action: "read-file".into(),
2758            effect: EffectVector {
2759                risk: 0.1,
2760                ..Default::default()
2761            },
2762            context: Default::default(),
2763            node_id: None,
2764            principal: None,
2765        };
2766        let result = engine.evaluate(&request);
2767        assert_eq!(result.decision, GovernanceDecision::Permit);
2768        assert!(!result.threshold_exceeded);
2769    }
2770
2771    #[test]
2772    fn genesis_privacy_violation_triggers_enforcement() {
2773        let engine = genesis_engine();
2774        let request = GovernanceRequest {
2775            agent_id: "agent-1".into(),
2776            action: "access-user-data".into(),
2777            effect: EffectVector {
2778                privacy: 0.8,
2779                ..Default::default()
2780            },
2781            context: Default::default(),
2782            node_id: None,
2783            principal: None,
2784        };
2785        let result = engine.evaluate(&request);
2786        // magnitude = 0.8 > 0.7 threshold; blocking rules exist -> Deny
2787        assert!(
2788            matches!(
2789                result.decision,
2790                GovernanceDecision::Deny(_) | GovernanceDecision::PermitWithWarning(_)
2791            ),
2792            "privacy violation should trigger warning or deny, got {:?}",
2793            result.decision,
2794        );
2795        assert!(result.threshold_exceeded);
2796    }
2797
2798    #[test]
2799    fn genesis_security_sensitive_blocked() {
2800        // GOV-002: security-sensitive actions blocked when threshold exceeded
2801        let engine = genesis_engine();
2802        let request = GovernanceRequest {
2803            agent_id: "agent-1".into(),
2804            action: "modify-firewall".into(),
2805            effect: EffectVector {
2806                security: 0.9,
2807                risk: 0.5,
2808                ..Default::default()
2809            },
2810            context: Default::default(),
2811            node_id: None,
2812            principal: None,
2813        };
2814        let result = engine.evaluate(&request);
2815        // magnitude = sqrt(0.81 + 0.25) ~ 1.03 > 0.7
2816        assert!(matches!(result.decision, GovernanceDecision::Deny(_)));
2817    }
2818
2819    #[test]
2820    fn genesis_fairness_bias_blocked() {
2821        // SOP-J001: bias/fairness evaluation blocking
2822        let engine = genesis_engine();
2823        let request = GovernanceRequest {
2824            agent_id: "ml-agent".into(),
2825            action: "evaluate-candidate".into(),
2826            effect: EffectVector {
2827                fairness: 0.9,
2828                ..Default::default()
2829            },
2830            context: Default::default(),
2831            node_id: None,
2832            principal: None,
2833        };
2834        let result = engine.evaluate(&request);
2835        assert!(
2836            matches!(result.decision, GovernanceDecision::Deny(_)),
2837            "fairness violation should be blocked by SOP-J001, got {:?}",
2838            result.decision,
2839        );
2840    }
2841
2842    #[test]
2843    fn genesis_agent_spawn_blocked_when_risky() {
2844        // GOV-006 + SOP-E002: agent spawn with high risk denied
2845        let engine = genesis_engine();
2846        let request = GovernanceRequest {
2847            agent_id: "orchestrator".into(),
2848            action: "agent.spawn".into(),
2849            effect: EffectVector {
2850                risk: 0.8,
2851                novelty: 0.5,
2852                ..Default::default()
2853            },
2854            context: Default::default(),
2855            node_id: None,
2856            principal: None,
2857        };
2858        let result = engine.evaluate(&request);
2859        // magnitude = sqrt(0.64 + 0.25) ~ 0.94 > 0.7
2860        assert!(matches!(result.decision, GovernanceDecision::Deny(_)));
2861    }
2862
2863    #[test]
2864    fn genesis_agent_spawn_permitted_when_safe() {
2865        // GOV-006: spawn with low effect should be permitted
2866        let engine = genesis_engine();
2867        let request = GovernanceRequest {
2868            agent_id: "orchestrator".into(),
2869            action: "agent.spawn".into(),
2870            effect: EffectVector {
2871                risk: 0.1,
2872                ..Default::default()
2873            },
2874            context: Default::default(),
2875            node_id: None,
2876            principal: None,
2877        };
2878        let result = engine.evaluate(&request);
2879        assert_eq!(result.decision, GovernanceDecision::Permit);
2880    }
2881
2882    #[test]
2883    fn genesis_data_protection_blocks_high_privacy() {
2884        // SOP-L005: data protection blocking
2885        let engine = genesis_engine();
2886        let request = GovernanceRequest {
2887            agent_id: "data-agent".into(),
2888            action: "export-pii".into(),
2889            effect: EffectVector {
2890                privacy: 0.9,
2891                risk: 0.3,
2892                ..Default::default()
2893            },
2894            context: Default::default(),
2895            node_id: None,
2896            principal: None,
2897        };
2898        let result = engine.evaluate(&request);
2899        // magnitude = sqrt(0.81 + 0.09) ~ 0.95 > 0.7
2900        assert!(matches!(result.decision, GovernanceDecision::Deny(_)));
2901    }
2902
2903    #[test]
2904    fn genesis_with_human_approval_escalates() {
2905        // Same rules but with human_approval_required = true
2906        let mut engine = GovernanceEngine::new(0.7, true);
2907        engine.add_rule(make_sop_rule(
2908            "GOV-001",
2909            RuleSeverity::Blocking,
2910            GovernanceBranch::Judicial,
2911            None,
2912        ));
2913
2914        let request = GovernanceRequest {
2915            agent_id: "agent-1".into(),
2916            action: "high-risk-op".into(),
2917            effect: EffectVector {
2918                risk: 0.9,
2919                ..Default::default()
2920            },
2921            context: Default::default(),
2922            node_id: None,
2923            principal: None,
2924        };
2925        let result = engine.evaluate(&request);
2926        assert!(
2927            matches!(result.decision, GovernanceDecision::EscalateToHuman(_)),
2928            "with human_approval, blocking should escalate, got {:?}",
2929            result.decision,
2930        );
2931    }
2932
2933    #[test]
2934    fn genesis_all_branches_represented() {
2935        let engine = genesis_engine();
2936        let legislative = engine.rules_by_branch(&GovernanceBranch::Legislative);
2937        let executive = engine.rules_by_branch(&GovernanceBranch::Executive);
2938        let judicial = engine.rules_by_branch(&GovernanceBranch::Judicial);
2939
2940        // Legislative: GOV-003, GOV-005, SOP-L001..L006, BP-001..003,
2941        // soul.binding_thread_intact = 12
2942        assert_eq!(
2943            legislative.len(),
2944            12,
2945            "legislative should have 12 rules, got {}",
2946            legislative.len()
2947        );
2948        // Executive: GOV-004, GOV-006, SOP-E001..E005 = 7
2949        assert_eq!(
2950            executive.len(),
2951            7,
2952            "executive should have 7 rules, got {}",
2953            executive.len()
2954        );
2955        // Judicial: GOV-001, GOV-002, GOV-007, SOP-J001..J004 = 7
2956        assert_eq!(
2957            judicial.len(),
2958            7,
2959            "judicial should have 7 rules, got {}",
2960            judicial.len()
2961        );
2962    }
2963
2964    #[test]
2965    fn genesis_blocking_rules_count() {
2966        let engine = genesis_engine();
2967        let blocking_count = engine
2968            .active_rules()
2969            .iter()
2970            .filter(|r| matches!(r.severity, RuleSeverity::Blocking | RuleSeverity::Critical))
2971            .count();
2972        // GOV-001, GOV-002, GOV-006, SOP-L001, SOP-L005, SOP-E002, SOP-J001,
2973        // BP-001, BP-002, BP-003, soul.binding_thread_intact = 11
2974        assert_eq!(blocking_count, 11, "should have exactly 11 blocking rules");
2975    }
2976
2977    #[test]
2978    fn genesis_warning_rules_count() {
2979        let engine = genesis_engine();
2980        let warning_count = engine
2981            .active_rules()
2982            .iter()
2983            .filter(|r| matches!(r.severity, RuleSeverity::Warning))
2984            .count();
2985        // GOV-003, GOV-005, SOP-L002, SOP-L003, SOP-L006,
2986        // SOP-E001, SOP-E003, SOP-J002, SOP-J003 = 9
2987        assert_eq!(warning_count, 9, "should have exactly 9 warning rules");
2988    }
2989
2990    #[test]
2991    fn genesis_advisory_rules_count() {
2992        let engine = genesis_engine();
2993        let advisory_count = engine
2994            .active_rules()
2995            .iter()
2996            .filter(|r| matches!(r.severity, RuleSeverity::Advisory))
2997            .count();
2998        // GOV-004, GOV-007, SOP-L004, SOP-E004, SOP-E005, SOP-J004 = 6
2999        assert_eq!(advisory_count, 6, "should have exactly 6 advisory rules");
3000    }
3001
3002    #[test]
3003    fn genesis_moderate_risk_with_only_warnings_permits_with_warning() {
3004        // Only warning-severity rules, no blocking: should PermitWithWarning
3005        let mut engine = GovernanceEngine::new(0.7, false);
3006        engine.add_rule(make_sop_rule(
3007            "SOP-L002",
3008            RuleSeverity::Warning,
3009            GovernanceBranch::Legislative,
3010            Some("governance"),
3011        ));
3012        engine.add_rule(make_sop_rule(
3013            "SOP-E001",
3014            RuleSeverity::Warning,
3015            GovernanceBranch::Executive,
3016            Some("engineering"),
3017        ));
3018
3019        let request = GovernanceRequest {
3020            agent_id: "dev-agent".into(),
3021            action: "write-code".into(),
3022            effect: EffectVector {
3023                risk: 0.5,
3024                novelty: 0.5,
3025                ..Default::default()
3026            },
3027            context: Default::default(),
3028            node_id: None,
3029            principal: None,
3030        };
3031        let result = engine.evaluate(&request);
3032        // magnitude ~ 0.71 > 0.7, only warnings -> PermitWithWarning
3033        assert!(
3034            matches!(result.decision, GovernanceDecision::PermitWithWarning(_)),
3035            "warning-only rules above threshold should PermitWithWarning, got {:?}",
3036            result.decision,
3037        );
3038    }
3039
3040    #[test]
3041    fn genesis_advisory_only_permits_above_threshold() {
3042        // Advisory rules alone never block or warn -- action is permitted
3043        let mut engine = GovernanceEngine::new(0.7, false);
3044        engine.add_rule(make_sop_rule(
3045            "GOV-004",
3046            RuleSeverity::Advisory,
3047            GovernanceBranch::Executive,
3048            None,
3049        ));
3050        engine.add_rule(make_sop_rule(
3051            "GOV-007",
3052            RuleSeverity::Advisory,
3053            GovernanceBranch::Judicial,
3054            None,
3055        ));
3056
3057        let request = GovernanceRequest {
3058            agent_id: "agent-1".into(),
3059            action: "novel-action".into(),
3060            effect: EffectVector {
3061                novelty: 0.9,
3062                ..Default::default()
3063            },
3064            context: Default::default(),
3065            node_id: None,
3066            principal: None,
3067        };
3068        let result = engine.evaluate(&request);
3069        assert_eq!(
3070            result.decision,
3071            GovernanceDecision::Permit,
3072            "advisory-only rules should still permit, got {:?}",
3073            result.decision
3074        );
3075        assert!(result.threshold_exceeded);
3076    }
3077
3078    #[test]
3079    fn genesis_evaluates_all_26_rules() {
3080        let engine = genesis_engine();
3081        let request = GovernanceRequest {
3082            agent_id: "agent-1".into(),
3083            action: "any-action".into(),
3084            effect: EffectVector {
3085                risk: 0.9,
3086                ..Default::default()
3087            },
3088            context: Default::default(),
3089            node_id: None,
3090            principal: None,
3091        };
3092        let result = engine.evaluate(&request);
3093        assert_eq!(
3094            result.evaluated_rules.len(),
3095            26,
3096            "all 26 rules should be evaluated, got {}",
3097            result.evaluated_rules.len(),
3098        );
3099    }
3100
3101    #[test]
3102    fn genesis_sop_categories_present() {
3103        let engine = genesis_engine();
3104        let all_rules = engine.active_rules();
3105        let categorized: Vec<_> = all_rules
3106            .iter()
3107            .filter(|r| r.sop_category.is_some())
3108            .collect();
3109        // 15 SOP + 3 browser_policy + 1 binding_thread have categories; 7 GOV rules do not
3110        assert_eq!(
3111            categorized.len(),
3112            19,
3113            "15 SOP + 3 browser_policy + 1 binding_thread rules should have categories"
3114        );
3115
3116        let categories: std::collections::HashSet<_> = categorized
3117            .iter()
3118            .map(|r| r.sop_category.as_deref().unwrap())
3119            .collect();
3120        assert!(categories.contains("governance"));
3121        assert!(categories.contains("ethics"));
3122        assert!(categories.contains("engineering"));
3123        assert!(categories.contains("lifecycle"));
3124        assert!(categories.contains("security"));
3125        assert!(categories.contains("quality"));
3126        assert!(categories.contains("browser_policy"));
3127    }
3128
3129    #[test]
3130    fn genesis_multi_dimension_high_effect_denied() {
3131        // Multiple dimensions contributing to high magnitude
3132        let engine = genesis_engine();
3133        let request = GovernanceRequest {
3134            agent_id: "agent-1".into(),
3135            action: "risky-novel-private".into(),
3136            effect: EffectVector {
3137                risk: 0.4,
3138                privacy: 0.4,
3139                novelty: 0.4,
3140                security: 0.4,
3141                fairness: 0.0,
3142            },
3143            context: Default::default(),
3144            node_id: None,
3145            principal: None,
3146        };
3147        let result = engine.evaluate(&request);
3148        // magnitude = sqrt(4 * 0.16) = sqrt(0.64) = 0.8 > 0.7
3149        assert!(
3150            matches!(result.decision, GovernanceDecision::Deny(_)),
3151            "combined multi-dimension effect should exceed threshold and deny, got {:?}",
3152            result.decision,
3153        );
3154        assert!(result.threshold_exceeded);
3155    }
3156
3157    // ── Environment-scoped governance tests ─────────────────────
3158
3159    fn make_env(
3160        class: crate::environment::EnvironmentClass,
3161        risk_threshold: f64,
3162    ) -> crate::environment::Environment {
3163        crate::environment::Environment {
3164            id: format!("{class}"),
3165            name: format!("{class}"),
3166            class,
3167            governance: crate::environment::GovernanceScope {
3168                risk_threshold,
3169                ..Default::default()
3170            },
3171            labels: std::collections::HashMap::new(),
3172        }
3173    }
3174
3175    #[test]
3176    fn dev_environment_allows_higher_risk() {
3177        use crate::environment::EnvironmentClass;
3178
3179        // Use an open engine (threshold 1.0) so the environment scope
3180        // is the controlling factor, not the engine's own threshold.
3181        let engine = GovernanceEngine::open();
3182
3183        // Dev environment with risk_threshold 0.9 -- lenient.
3184        let dev_env = make_env(EnvironmentClass::Development, 0.9);
3185
3186        // High-risk request: magnitude 0.8
3187        let request = GovernanceRequest::new("agent-1", "deploy").with_effect(EffectVector {
3188            risk: 0.8,
3189            ..Default::default()
3190        });
3191
3192        let result = engine.evaluate_in_environment(&request, &dev_env);
3193        // 0.8 < 0.9 (dev threshold) => should be permitted.
3194        assert_eq!(
3195            result.decision,
3196            GovernanceDecision::Permit,
3197            "dev should allow risk 0.8 with threshold 0.9, got {:?}",
3198            result.decision,
3199        );
3200    }
3201
3202    #[test]
3203    fn prod_environment_blocks_high_risk() {
3204        use crate::environment::EnvironmentClass;
3205
3206        let mut engine = GovernanceEngine::new(1.0, false);
3207        engine.add_rule(make_rule(
3208            "sec-check",
3209            RuleSeverity::Blocking,
3210            GovernanceBranch::Judicial,
3211        ));
3212
3213        // Prod environment with risk_threshold 0.6 -- halved to 0.3.
3214        let prod_env = make_env(EnvironmentClass::Production, 0.6);
3215
3216        // Moderate request: magnitude 0.4
3217        let request = GovernanceRequest::new("agent-1", "deploy").with_effect(EffectVector {
3218            risk: 0.4,
3219            ..Default::default()
3220        });
3221
3222        let result = engine.evaluate_in_environment(&request, &prod_env);
3223        // 0.4 > 0.3 (prod adjusted = 0.6 * 0.5) => denied.
3224        assert!(
3225            matches!(result.decision, GovernanceDecision::Deny(_)),
3226            "prod should deny risk 0.4 with adjusted threshold 0.3, got {:?}",
3227            result.decision,
3228        );
3229    }
3230
3231    #[test]
3232    fn staging_uses_normal_thresholds() {
3233        use crate::environment::EnvironmentClass;
3234
3235        let mut engine = GovernanceEngine::new(1.0, false);
3236        engine.add_rule(make_rule(
3237            "sec-check",
3238            RuleSeverity::Blocking,
3239            GovernanceBranch::Judicial,
3240        ));
3241
3242        // Staging environment with risk_threshold 0.6.
3243        let staging_env = make_env(EnvironmentClass::Staging, 0.6);
3244
3245        // Moderate request: magnitude 0.5
3246        let request = GovernanceRequest::new("agent-1", "test-deploy").with_effect(EffectVector {
3247            risk: 0.5,
3248            ..Default::default()
3249        });
3250
3251        let result = engine.evaluate_in_environment(&request, &staging_env);
3252        // 0.5 < 0.6 => permitted.
3253        assert_eq!(
3254            result.decision,
3255            GovernanceDecision::Permit,
3256            "staging should allow risk 0.5 with threshold 0.6, got {:?}",
3257            result.decision,
3258        );
3259
3260        // Higher request: magnitude 0.7
3261        let request2 = GovernanceRequest::new("agent-1", "test-deploy").with_effect(EffectVector {
3262            risk: 0.7,
3263            ..Default::default()
3264        });
3265        let result2 = engine.evaluate_in_environment(&request2, &staging_env);
3266        // 0.7 > 0.6 => denied.
3267        assert!(
3268            matches!(result2.decision, GovernanceDecision::Deny(_)),
3269            "staging should deny risk 0.7 with threshold 0.6, got {:?}",
3270            result2.decision,
3271        );
3272    }
3273
3274    #[test]
3275    fn environment_governance_same_request_different_envs() {
3276        use crate::environment::EnvironmentClass;
3277
3278        let engine = GovernanceEngine::open();
3279
3280        let dev = make_env(EnvironmentClass::Development, 0.9);
3281        let prod = make_env(EnvironmentClass::Production, 0.6);
3282
3283        // magnitude = 0.5
3284        let request = GovernanceRequest::new("agent-1", "write-file").with_effect(EffectVector {
3285            risk: 0.5,
3286            ..Default::default()
3287        });
3288
3289        let dev_result = engine.evaluate_in_environment(&request, &dev);
3290        let prod_result = engine.evaluate_in_environment(&request, &prod);
3291
3292        // Dev: 0.5 < 0.9 => permit
3293        assert_eq!(dev_result.decision, GovernanceDecision::Permit);
3294        // Prod: 0.5 > 0.3 (0.6 * 0.5) => deny
3295        assert!(matches!(prod_result.decision, GovernanceDecision::Deny(_)));
3296    }
3297
3298    // ── Trajectory recorder tests ───────────────────────────────
3299
3300    #[test]
3301    fn trajectory_records_and_retrieves() {
3302        use chrono::Utc;
3303        let mut recorder = TrajectoryRecorder::new(100);
3304        recorder.record(TrajectoryRecord {
3305            agent_id: "agent-1".into(),
3306            action: "tool.execute".into(),
3307            context: serde_json::json!({"tool": "read_file"}),
3308            outcome: TrajectoryOutcome::Success { reward: 1.0 },
3309            timestamp: Utc::now(),
3310        });
3311        assert_eq!(recorder.len(), 1);
3312        assert!(!recorder.is_empty());
3313        assert_eq!(recorder.agent_trajectory("agent-1").len(), 1);
3314        assert_eq!(recorder.agent_trajectory("agent-2").len(), 0);
3315    }
3316
3317    #[test]
3318    fn trajectory_extracts_patterns() {
3319        use chrono::Utc;
3320        let mut recorder = TrajectoryRecorder::new(100);
3321
3322        for _ in 0..5 {
3323            recorder.record(TrajectoryRecord {
3324                agent_id: "a".into(),
3325                action: "tool.execute".into(),
3326                context: serde_json::json!({}),
3327                outcome: TrajectoryOutcome::Success { reward: 1.0 },
3328                timestamp: Utc::now(),
3329            });
3330        }
3331        for _ in 0..3 {
3332            recorder.record(TrajectoryRecord {
3333                agent_id: "a".into(),
3334                action: "tool.read".into(),
3335                context: serde_json::json!({}),
3336                outcome: TrajectoryOutcome::Success { reward: 0.5 },
3337                timestamp: Utc::now(),
3338            });
3339        }
3340        recorder.record(TrajectoryRecord {
3341            agent_id: "a".into(),
3342            action: "tool.fail".into(),
3343            context: serde_json::json!({}),
3344            outcome: TrajectoryOutcome::Failure {
3345                reason: "err".into(),
3346            },
3347            timestamp: Utc::now(),
3348        });
3349
3350        let patterns = recorder.extract_patterns();
3351        assert_eq!(patterns[0].0, "tool.execute");
3352        assert_eq!(patterns[0].1, 5);
3353        assert_eq!(patterns[1].0, "tool.read");
3354        assert_eq!(patterns[1].1, 3);
3355        // Failures are not in patterns.
3356        assert!(patterns.iter().all(|(a, _)| a != "tool.fail"));
3357    }
3358
3359    #[test]
3360    fn trajectory_max_records_eviction() {
3361        use chrono::Utc;
3362        let mut recorder = TrajectoryRecorder::new(3);
3363        for i in 0..5 {
3364            recorder.record(TrajectoryRecord {
3365                agent_id: format!("agent-{i}"),
3366                action: "act".into(),
3367                context: serde_json::json!({}),
3368                outcome: TrajectoryOutcome::Pending,
3369                timestamp: Utc::now(),
3370            });
3371        }
3372        assert_eq!(recorder.len(), 3);
3373        // Oldest two (agent-0, agent-1) should be evicted.
3374        assert!(recorder.agent_trajectory("agent-0").is_empty());
3375        assert!(recorder.agent_trajectory("agent-1").is_empty());
3376        assert_eq!(recorder.agent_trajectory("agent-2").len(), 1);
3377        assert_eq!(recorder.agent_trajectory("agent-4").len(), 1);
3378    }
3379
3380    #[test]
3381    fn trajectory_empty_patterns() {
3382        let recorder = TrajectoryRecorder::new(10);
3383        assert!(recorder.is_empty());
3384        assert!(recorder.extract_patterns().is_empty());
3385    }
3386
3387    // ── Sprint 09a: serde roundtrip + behavioral tests ─────────
3388
3389    #[test]
3390    fn governance_branch_serde_roundtrip() {
3391        for branch in [
3392            GovernanceBranch::Legislative,
3393            GovernanceBranch::Executive,
3394            GovernanceBranch::Judicial,
3395        ] {
3396            let json = serde_json::to_string(&branch).unwrap();
3397            let restored: GovernanceBranch = serde_json::from_str(&json).unwrap();
3398            assert_eq!(restored, branch);
3399        }
3400    }
3401
3402    #[test]
3403    fn rule_severity_serde_roundtrip() {
3404        for severity in [
3405            RuleSeverity::Advisory,
3406            RuleSeverity::Warning,
3407            RuleSeverity::Blocking,
3408            RuleSeverity::Critical,
3409        ] {
3410            let json = serde_json::to_string(&severity).unwrap();
3411            let restored: RuleSeverity = serde_json::from_str(&json).unwrap();
3412            assert_eq!(restored, severity);
3413        }
3414    }
3415
3416    #[test]
3417    fn rule_severity_display() {
3418        assert_eq!(RuleSeverity::Advisory.to_string(), "advisory");
3419        assert_eq!(RuleSeverity::Warning.to_string(), "warning");
3420        assert_eq!(RuleSeverity::Blocking.to_string(), "blocking");
3421        assert_eq!(RuleSeverity::Critical.to_string(), "critical");
3422    }
3423
3424    #[test]
3425    fn governance_decision_serde_roundtrip_all_variants() {
3426        let variants = vec![
3427            GovernanceDecision::Permit,
3428            GovernanceDecision::PermitWithWarning("low risk".into()),
3429            GovernanceDecision::EscalateToHuman("needs review".into()),
3430            GovernanceDecision::Deny("policy violation".into()),
3431        ];
3432        for decision in variants {
3433            let json = serde_json::to_string(&decision).unwrap();
3434            let restored: GovernanceDecision = serde_json::from_str(&json).unwrap();
3435            assert_eq!(restored, decision);
3436        }
3437    }
3438
3439    #[test]
3440    fn governance_result_serde_roundtrip() {
3441        let result = GovernanceResult {
3442            decision: GovernanceDecision::Permit,
3443            evaluated_rules: vec!["rule-1".into(), "rule-2".into()],
3444            effect: EffectVector {
3445                risk: 0.3,
3446                ..Default::default()
3447            },
3448            threshold_exceeded: false,
3449            principal: None,
3450        };
3451        let json = serde_json::to_string(&result).unwrap();
3452        let restored: GovernanceResult = serde_json::from_str(&json).unwrap();
3453        assert_eq!(restored.decision, GovernanceDecision::Permit);
3454        assert_eq!(restored.evaluated_rules.len(), 2);
3455        assert!(!restored.threshold_exceeded);
3456    }
3457
3458    #[test]
3459    fn governance_result_deny_serde_roundtrip() {
3460        let result = GovernanceResult {
3461            decision: GovernanceDecision::Deny("threshold exceeded".into()),
3462            evaluated_rules: vec![],
3463            effect: EffectVector {
3464                risk: 0.9,
3465                security: 0.8,
3466                ..Default::default()
3467            },
3468            threshold_exceeded: true,
3469            principal: None,
3470        };
3471        let json = serde_json::to_string(&result).unwrap();
3472        let restored: GovernanceResult = serde_json::from_str(&json).unwrap();
3473        assert!(restored.threshold_exceeded);
3474        assert!(matches!(restored.decision, GovernanceDecision::Deny(_)));
3475    }
3476
3477    #[test]
3478    fn trajectory_outcome_serde_roundtrip() {
3479        let variants: Vec<TrajectoryOutcome> = vec![
3480            TrajectoryOutcome::Success { reward: 1.5 },
3481            TrajectoryOutcome::Failure {
3482                reason: "timeout".into(),
3483            },
3484            TrajectoryOutcome::Pending,
3485        ];
3486        for outcome in variants {
3487            let json = serde_json::to_string(&outcome).unwrap();
3488            let _restored: TrajectoryOutcome = serde_json::from_str(&json).unwrap();
3489        }
3490    }
3491
3492    #[test]
3493    fn trajectory_record_serde_roundtrip() {
3494        use chrono::Utc;
3495        let record = TrajectoryRecord {
3496            agent_id: "agent-1".into(),
3497            action: "tool.execute".into(),
3498            context: serde_json::json!({"tool": "read_file", "path": "/etc/hosts"}),
3499            outcome: TrajectoryOutcome::Success { reward: 1.0 },
3500            timestamp: Utc::now(),
3501        };
3502        let json = serde_json::to_string(&record).unwrap();
3503        let restored: TrajectoryRecord = serde_json::from_str(&json).unwrap();
3504        assert_eq!(restored.agent_id, "agent-1");
3505        assert_eq!(restored.action, "tool.execute");
3506    }
3507
3508    #[test]
3509    fn governance_rule_with_sop_fields_roundtrip() {
3510        let rule = GovernanceRule {
3511            id: "SOP-001".into(),
3512            description: "Do not access prod data".into(),
3513            branch: GovernanceBranch::Legislative,
3514            severity: RuleSeverity::Critical,
3515            active: true,
3516            reference_url: Some("https://sops.example.com/001".into()),
3517            sop_category: Some("data-access".into()),
3518            rule_type: Default::default(),
3519            action_selector: None,
3520            tool_selector: None,
3521            force_on_match: false,
3522        };
3523        let json = serde_json::to_string(&rule).unwrap();
3524        let restored: GovernanceRule = serde_json::from_str(&json).unwrap();
3525        assert_eq!(
3526            restored.reference_url.unwrap(),
3527            "https://sops.example.com/001"
3528        );
3529        assert_eq!(restored.sop_category.unwrap(), "data-access");
3530    }
3531
3532    #[test]
3533    fn effect_vector_default_is_zero() {
3534        let v = EffectVector::default();
3535        assert!((v.magnitude() - 0.0).abs() < f64::EPSILON);
3536        assert!(!v.any_exceeds(0.0));
3537    }
3538
3539    #[test]
3540    fn effect_vector_max_dimension() {
3541        let v = EffectVector {
3542            risk: 0.1,
3543            fairness: 0.5,
3544            privacy: 0.3,
3545            novelty: 0.9,
3546            security: 0.2,
3547        };
3548        assert!((v.max_dimension() - 0.9).abs() < f64::EPSILON);
3549    }
3550
3551    #[cfg(feature = "exochain")]
3552    mod rvf_bridge_tests {
3553        use super::*;
3554
3555        #[test]
3556        fn decision_to_policy_check() {
3557            use rvf_types::witness::PolicyCheck;
3558
3559            assert_eq!(
3560                GovernanceDecision::Permit.to_rvf_policy_check(),
3561                PolicyCheck::Allowed,
3562            );
3563            assert_eq!(
3564                GovernanceDecision::PermitWithWarning("low risk".into()).to_rvf_policy_check(),
3565                PolicyCheck::Confirmed,
3566            );
3567            assert_eq!(
3568                GovernanceDecision::EscalateToHuman("needs review".into()).to_rvf_policy_check(),
3569                PolicyCheck::Confirmed,
3570            );
3571            assert_eq!(
3572                GovernanceDecision::Deny("blocked".into()).to_rvf_policy_check(),
3573                PolicyCheck::Denied,
3574            );
3575        }
3576
3577        #[test]
3578        fn open_engine_maps_to_autonomous() {
3579            use rvf_types::witness::GovernanceMode;
3580
3581            let engine = GovernanceEngine::open();
3582            assert_eq!(engine.to_rvf_mode(), GovernanceMode::Autonomous);
3583        }
3584
3585        #[test]
3586        fn strict_engine_maps_to_restricted() {
3587            use rvf_types::witness::GovernanceMode;
3588
3589            let engine = GovernanceEngine::new(0.5, false);
3590            assert_eq!(engine.to_rvf_mode(), GovernanceMode::Restricted);
3591        }
3592
3593        #[test]
3594        fn human_approval_maps_to_approved() {
3595            use rvf_types::witness::GovernanceMode;
3596
3597            let engine = GovernanceEngine::new(0.5, true);
3598            assert_eq!(engine.to_rvf_mode(), GovernanceMode::Approved);
3599        }
3600
3601        #[test]
3602        fn to_rvf_policy_mode_matches() {
3603            use rvf_types::witness::GovernanceMode;
3604
3605            let open = GovernanceEngine::open();
3606            let policy = open.to_rvf_policy();
3607            assert_eq!(policy.mode, GovernanceMode::Autonomous);
3608
3609            let strict = GovernanceEngine::new(0.3, false);
3610            let policy = strict.to_rvf_policy();
3611            assert_eq!(policy.mode, GovernanceMode::Restricted);
3612
3613            let human = GovernanceEngine::new(0.3, true);
3614            let policy = human.to_rvf_policy();
3615            assert_eq!(policy.mode, GovernanceMode::Approved);
3616        }
3617
3618        #[test]
3619        fn governance_result_to_task_outcome() {
3620            use rvf_types::witness::TaskOutcome;
3621
3622            let permit_result = GovernanceResult {
3623                decision: GovernanceDecision::Permit,
3624                evaluated_rules: vec![],
3625                effect: EffectVector::default(),
3626                threshold_exceeded: false,
3627                principal: None,
3628            };
3629            assert_eq!(
3630                permit_result.to_rvf_task_outcome() as u8,
3631                TaskOutcome::Solved as u8
3632            );
3633
3634            let deny_result = GovernanceResult {
3635                decision: GovernanceDecision::Deny("blocked".into()),
3636                evaluated_rules: vec![],
3637                effect: EffectVector::default(),
3638                threshold_exceeded: true,
3639                principal: None,
3640            };
3641            assert_eq!(
3642                deny_result.to_rvf_task_outcome() as u8,
3643                TaskOutcome::Failed as u8
3644            );
3645
3646            let escalate_result = GovernanceResult {
3647                decision: GovernanceDecision::EscalateToHuman("review".into()),
3648                evaluated_rules: vec![],
3649                effect: EffectVector::default(),
3650                threshold_exceeded: true,
3651                principal: None,
3652            };
3653            assert_eq!(
3654                escalate_result.to_rvf_task_outcome() as u8,
3655                TaskOutcome::Skipped as u8
3656            );
3657        }
3658    }
3659
3660    // ── ChainLoggable integration tests ────────────────────────
3661
3662    #[cfg(feature = "exochain")]
3663    mod chain_logging_tests {
3664        use super::*;
3665        use std::sync::Arc;
3666
3667        #[test]
3668        fn evaluate_logged_records_permit() {
3669            let cm = Arc::new(crate::chain::ChainManager::new(0, 100));
3670            let initial_len = cm.len();
3671
3672            let mut engine = GovernanceEngine::new(0.5, false);
3673            engine.add_rule(GovernanceRule {
3674                id: "sec".into(),
3675                description: "test".into(),
3676                branch: GovernanceBranch::Judicial,
3677                severity: RuleSeverity::Blocking,
3678                active: true,
3679                reference_url: None,
3680                sop_category: None,
3681                rule_type: Default::default(),
3682                action_selector: None,
3683                tool_selector: None,
3684                force_on_match: false,
3685            });
3686
3687            let request = GovernanceRequest {
3688                agent_id: "agent-1".into(),
3689                action: "tool.read".into(),
3690                effect: EffectVector {
3691                    risk: 0.1,
3692                    ..Default::default()
3693                },
3694                context: Default::default(),
3695                node_id: None,
3696                principal: None,
3697            };
3698
3699            let result = engine.evaluate_logged(&request, Some(&cm));
3700            assert!(matches!(result.decision, GovernanceDecision::Permit));
3701            assert_eq!(cm.len(), initial_len + 1);
3702
3703            let events = cm.tail(1);
3704            assert_eq!(events[0].kind, "governance.permit");
3705            assert_eq!(events[0].source, "governance");
3706        }
3707
3708        #[test]
3709        fn evaluate_logged_records_deny() {
3710            let cm = Arc::new(crate::chain::ChainManager::new(0, 100));
3711
3712            let mut engine = GovernanceEngine::new(0.5, false);
3713            engine.add_rule(GovernanceRule {
3714                id: "sec".into(),
3715                description: "test".into(),
3716                branch: GovernanceBranch::Judicial,
3717                severity: RuleSeverity::Blocking,
3718                active: true,
3719                reference_url: None,
3720                sop_category: None,
3721                rule_type: Default::default(),
3722                action_selector: None,
3723                tool_selector: None,
3724                force_on_match: false,
3725            });
3726
3727            let request = GovernanceRequest {
3728                agent_id: "agent-1".into(),
3729                action: "tool.exec".into(),
3730                effect: EffectVector {
3731                    risk: 0.9,
3732                    ..Default::default()
3733                },
3734                context: Default::default(),
3735                node_id: None,
3736                principal: None,
3737            };
3738
3739            let result = engine.evaluate_logged(&request, Some(&cm));
3740            assert!(matches!(result.decision, GovernanceDecision::Deny(_)));
3741
3742            let events = cm.tail(1);
3743            assert_eq!(events[0].kind, "governance.deny");
3744            let payload = events[0].payload.as_ref().unwrap();
3745            assert_eq!(payload["agent_id"], "agent-1");
3746            assert!(payload["threshold_exceeded"].as_bool().unwrap());
3747        }
3748
3749        #[test]
3750        fn evaluate_logged_without_chain_still_works() {
3751            let engine = GovernanceEngine::new(0.5, false);
3752            let request = GovernanceRequest {
3753                agent_id: "agent-1".into(),
3754                action: "tool.read".into(),
3755                effect: EffectVector::default(),
3756                context: Default::default(),
3757                node_id: None,
3758                principal: None,
3759            };
3760
3761            // Passing None should not panic, just skip logging
3762            let result = engine.evaluate_logged(&request, None);
3763            assert!(matches!(result.decision, GovernanceDecision::Permit));
3764        }
3765
3766        #[test]
3767        fn chain_log_result_standalone() {
3768            let cm = crate::chain::ChainManager::new(0, 100);
3769            let initial_len = cm.len();
3770
3771            let mut engine = GovernanceEngine::new(0.5, true);
3772            engine.add_rule(GovernanceRule {
3773                id: "sec".into(),
3774                description: "test".into(),
3775                branch: GovernanceBranch::Judicial,
3776                severity: RuleSeverity::Blocking,
3777                active: true,
3778                reference_url: None,
3779                sop_category: None,
3780                rule_type: Default::default(),
3781                action_selector: None,
3782                tool_selector: None,
3783                force_on_match: false,
3784            });
3785
3786            let request = GovernanceRequest {
3787                agent_id: "agent-1".into(),
3788                action: "tool.exec".into(),
3789                effect: EffectVector {
3790                    risk: 0.8,
3791                    ..Default::default()
3792                },
3793                context: Default::default(),
3794                node_id: None,
3795                principal: None,
3796            };
3797
3798            let result = engine.evaluate(&request);
3799            GovernanceEngine::chain_log_result(&cm, &request, &result);
3800            assert_eq!(cm.len(), initial_len + 1);
3801
3802            let events = cm.tail(1);
3803            assert_eq!(events[0].kind, "governance.defer");
3804        }
3805    }
3806
3807    // ── WEFT-634: action/tool selectors ─────────────────────────
3808
3809    #[test]
3810    fn selector_matches_exact_and_prefix() {
3811        assert!(selector_matches("tool.agent_spawn", "tool.agent_spawn"));
3812        assert!(!selector_matches("tool.agent_spawn", "tool.exec"));
3813        assert!(selector_matches("tool.*", "tool.agent_spawn"));
3814        assert!(selector_matches("tool.agent_*", "tool.agent_spawn"));
3815        assert!(!selector_matches("tool.agent_*", "tool.exec"));
3816        assert!(selector_matches("", "anything"));
3817    }
3818
3819    #[test]
3820    fn action_selector_scopes_blocking_rule() {
3821        let mut engine = GovernanceEngine::new(0.5, false);
3822        let mut rule = make_rule(
3823            "spawn-only",
3824            RuleSeverity::Blocking,
3825            GovernanceBranch::Legislative,
3826        );
3827        rule.action_selector = Some("tool.agent_spawn".into());
3828        engine.add_rule(rule);
3829
3830        // High magnitude but wrong action → rule does not apply → Permit.
3831        let other = GovernanceRequest {
3832            agent_id: "a".into(),
3833            action: "tool.exec".into(),
3834            effect: EffectVector {
3835                risk: 0.9,
3836                security: 0.9,
3837                ..Default::default()
3838            },
3839            context: Default::default(),
3840            node_id: None,
3841            principal: None,
3842        };
3843        assert_eq!(
3844            engine.evaluate(&other).decision,
3845            GovernanceDecision::Permit
3846        );
3847        assert!(engine.evaluate(&other).evaluated_rules.is_empty());
3848
3849        // Matching action + high magnitude → Deny.
3850        let spawn = GovernanceRequest {
3851            agent_id: "a".into(),
3852            action: "tool.agent_spawn".into(),
3853            effect: EffectVector {
3854                risk: 0.9,
3855                security: 0.9,
3856                ..Default::default()
3857            },
3858            context: Default::default(),
3859            node_id: None,
3860            principal: None,
3861        };
3862        assert!(matches!(
3863            engine.evaluate(&spawn).decision,
3864            GovernanceDecision::Deny(_)
3865        ));
3866        assert_eq!(
3867            engine.evaluate(&spawn).evaluated_rules,
3868            vec!["spawn-only".to_string()]
3869        );
3870    }
3871
3872    #[test]
3873    fn tool_selector_matches_context_tool() {
3874        let mut engine = GovernanceEngine::new(0.5, false);
3875        let mut rule = make_rule(
3876            "tool-spawn",
3877            RuleSeverity::Blocking,
3878            GovernanceBranch::Legislative,
3879        );
3880        rule.tool_selector = Some("agent_spawn".into());
3881        engine.add_rule(rule);
3882
3883        let miss = GovernanceRequest::new("a", "tool.exec")
3884            .with_effect(EffectVector {
3885                risk: 0.9,
3886                security: 0.9,
3887                ..Default::default()
3888            })
3889            .with_context_entry("tool", "exec");
3890        assert_eq!(engine.evaluate(&miss).decision, GovernanceDecision::Permit);
3891
3892        let hit = GovernanceRequest::new("a", "tool.exec")
3893            .with_effect(EffectVector {
3894                risk: 0.9,
3895                security: 0.9,
3896                ..Default::default()
3897            })
3898            .with_context_entry("tool", "agent_spawn");
3899        assert!(matches!(
3900            engine.evaluate(&hit).decision,
3901            GovernanceDecision::Deny(_)
3902        ));
3903    }
3904
3905    #[test]
3906    fn force_on_match_applies_without_magnitude() {
3907        let mut engine = GovernanceEngine::new(0.99, false); // high threshold
3908        engine.add_rule(GovernanceRule::spawn_requires_approval());
3909
3910        // Low effect — would not trip magnitude, but force_on_match + selectors.
3911        let req = GovernanceRequest::new("agent-1", "tool.agent_spawn")
3912            .with_effect(EffectVector {
3913                risk: 0.1,
3914                ..Default::default()
3915            })
3916            .with_context_entry("tool", "agent_spawn");
3917        let result = engine.evaluate(&req);
3918        assert!(
3919            matches!(result.decision, GovernanceDecision::Deny(_)),
3920            "expected Deny from force_on_match, got {:?}",
3921            result.decision
3922        );
3923        assert!(!result.threshold_exceeded);
3924        assert!(result.evaluated_rules.contains(&"SPAWN-APPROVAL".into()));
3925    }
3926
3927    #[test]
3928    fn spawn_approval_escalates_when_human_approval_required() {
3929        // WEFT-633 foundation: EscalateToHuman → gate Defer path.
3930        let mut engine = GovernanceEngine::new(0.99, true);
3931        engine.add_rule(GovernanceRule::spawn_requires_approval());
3932
3933        let req = GovernanceRequest::new("agent-1", "tool.agent_spawn")
3934            .with_context_entry("tool", "agent_spawn");
3935        let result = engine.evaluate(&req);
3936        match result.decision {
3937            GovernanceDecision::EscalateToHuman(ref reason) => {
3938                assert!(
3939                    reason.contains("SPAWN-APPROVAL") || reason.contains("force-match"),
3940                    "reason={reason}"
3941                );
3942            }
3943            other => panic!("expected EscalateToHuman, got {other:?}"),
3944        }
3945    }
3946
3947    #[test]
3948    fn selector_backward_compat_magnitude_only_rules() {
3949        // Magnitude-only rules (no selectors) still apply to every action.
3950        let mut engine = GovernanceEngine::new(0.5, false);
3951        engine.add_rule(make_rule(
3952            "global",
3953            RuleSeverity::Blocking,
3954            GovernanceBranch::Judicial,
3955        ));
3956        let req = GovernanceRequest {
3957            agent_id: "a".into(),
3958            action: "anything".into(),
3959            effect: EffectVector {
3960                risk: 0.9,
3961                ..Default::default()
3962            },
3963            context: Default::default(),
3964            node_id: None,
3965            principal: None,
3966        };
3967        assert!(matches!(
3968            engine.evaluate(&req).decision,
3969            GovernanceDecision::Deny(_)
3970        ));
3971    }
3972
3973    #[test]
3974    fn rule_serde_preserves_selectors() {
3975        let rule = GovernanceRule::spawn_requires_approval();
3976        let json = serde_json::to_string(&rule).unwrap();
3977        let restored: GovernanceRule = serde_json::from_str(&json).unwrap();
3978        assert_eq!(
3979            restored.action_selector.as_deref(),
3980            Some("tool.agent_spawn")
3981        );
3982        assert_eq!(restored.tool_selector.as_deref(), Some("agent_spawn"));
3983        assert!(restored.force_on_match);
3984        // Older JSON without selector fields deserialises cleanly.
3985        let legacy = r#"{"id":"x","description":"d","branch":"Legislative","severity":"Blocking"}"#;
3986        let old: GovernanceRule = serde_json::from_str(legacy).unwrap();
3987        assert!(old.action_selector.is_none());
3988        assert!(!old.force_on_match);
3989    }
3990
3991    // ── WEFT-636: gate principals ──────────────────────────────
3992
3993    #[test]
3994    fn principal_propagates_to_result() {
3995        let engine = GovernanceEngine::open();
3996        let principal = GatePrincipal::agent("child-1")
3997            .with_user("user-alice")
3998            .with_parent("parent-agent")
3999            .with_conv("conv-P");
4000        let req = GovernanceRequest::new("child-1", "tool.read_file").with_principal(principal);
4001        let result = engine.evaluate(&req);
4002        let p = result.principal.expect("principal on result");
4003        assert_eq!(p.agent_id, "child-1");
4004        assert_eq!(p.user_id.as_deref(), Some("user-alice"));
4005        assert_eq!(p.parent_agent_id.as_deref(), Some("parent-agent"));
4006        assert_eq!(p.conv_id.as_deref(), Some("conv-P"));
4007    }
4008
4009    #[test]
4010    fn principal_synthesised_from_agent_id_when_absent() {
4011        let engine = GovernanceEngine::open();
4012        let req = GovernanceRequest {
4013            agent_id: "solo".into(),
4014            action: "noop".into(),
4015            effect: EffectVector::default(),
4016            context: Default::default(),
4017            node_id: None,
4018            principal: None,
4019        };
4020        let p = engine.evaluate(&req).principal.expect("synthesised");
4021        assert_eq!(p.agent_id, "solo");
4022        assert!(p.user_id.is_none());
4023    }
4024
4025    #[test]
4026    fn gate_principal_serde_roundtrip() {
4027        let p = GatePrincipal::agent("a")
4028            .with_user("u")
4029            .with_parent("p")
4030            .with_conv("c");
4031        let json = serde_json::to_string(&p).unwrap();
4032        let restored: GatePrincipal = serde_json::from_str(&json).unwrap();
4033        assert_eq!(restored, p);
4034    }
4035
4036    #[test]
4037    fn gate_principal_old_json_parses_and_new_fields_stay_off_the_wire() {
4038        // A pre-Phase-2 chain payload.
4039        let old = r#"{"agent_id":"a","user_id":"u","parent_agent_id":"p","conv_id":"c"}"#;
4040        let p: GatePrincipal = serde_json::from_str(old).unwrap();
4041        assert_eq!(p.project_id(), None);
4042        assert_eq!(p.instance_id(), None);
4043        // And it serialises back to the same bytes: nothing new appears.
4044        assert_eq!(serde_json::to_string(&p).unwrap(), old);
4045        // Missing everything still parses (all fields default).
4046        let bare: GatePrincipal = serde_json::from_str("{}").unwrap();
4047        assert_eq!(bare, GatePrincipal::default());
4048    }
4049
4050    #[test]
4051    fn gate_principal_project_and_instance_round_trip() {
4052        let att = ProjectAttestation::from_verified(
4053            "01JB8Z3Q0V6X9KQ4M2N7T5R1WD",
4054            AttestSource::UserForward,
4055        );
4056        assert_eq!(att.source(), AttestSource::UserForward);
4057        let p = GatePrincipal::agent("a")
4058            .with_project(&att)
4059            .with_instance("6a3803d5f059902a1c6dafbc9ba47292");
4060        let v = serde_json::to_value(&p).unwrap();
4061        assert_eq!(v["project_id"], "01JB8Z3Q0V6X9KQ4M2N7T5R1WD");
4062        assert_eq!(v["instance_id"], "6a3803d5f059902a1c6dafbc9ba47292");
4063        let back: GatePrincipal = serde_json::from_value(v).unwrap();
4064        // Serialised for audit, never deserialised (nothing can assert it).
4065        assert_eq!(back.project_id(), None);
4066        assert_eq!(back.instance_id(), None);
4067        assert_eq!(back.agent_id, p.agent_id);
4068    }
4069
4070    // ── WEFT-506: explicit EffectVector schema per gate family ──
4071
4072    #[test]
4073    fn gate_effect_kind_dimensions_pinned() {
4074        let auth = GateEffectKind::AuthCredentialRegister.effect_vector();
4075        assert!((auth.security - 0.8).abs() < 1e-12);
4076        assert!((auth.privacy - 0.6).abs() < 1e-12);
4077        assert!((auth.risk - 0.5).abs() < 1e-12);
4078
4079        let config = GateEffectKind::ConfigSet.effect_vector();
4080        assert!((config.risk - 0.35).abs() < 1e-12);
4081        assert!((config.security - 0.4).abs() < 1e-12);
4082
4083        let secret = GateEffectKind::ConfigSecretSet.effect_vector();
4084        assert!(secret.privacy > 0.8);
4085        assert!(secret.security > 0.8);
4086
4087        let a2a = GateEffectKind::A2aRouting.effect_vector();
4088        assert!((a2a.risk - 0.25).abs() < 1e-12);
4089
4090        let cron = GateEffectKind::CronAdd.effect_vector();
4091        assert!((cron.risk - 0.2).abs() < 1e-12);
4092        assert!((cron.security - 0.1).abs() < 1e-12);
4093
4094        // Context merge embeds the effect object.
4095        let ctx = with_effect_context(
4096            serde_json::json!({"pid": 1}),
4097            &GateEffectKind::AuthTokenIssue.effect_vector(),
4098        );
4099        assert!(ctx.get("effect").is_some());
4100        assert_eq!(ctx["effect"]["security"], 0.7);
4101        assert_eq!(ctx["pid"], 1);
4102    }
4103}