Skip to main content

arbiter/
suggestor.rs

1use std::sync::{Arc, LazyLock};
2
3use converge_core::FlowGateInput;
4use converge_pack::{
5    AgentEffect, Context, ContextFact, ContextKey, DiagnosticPayload, FactId, FactPayload,
6    ProposalId, Provenance, Suggestor, fact::ProposedFact,
7};
8use ed25519_dalek::VerifyingKey;
9use serde::{Deserialize, Serialize};
10
11#[cfg(feature = "analysis")]
12use crate::analysis::{
13    CedarAnalysisBackend, CedarAnalysisExecutionStatus, CedarAnalysisInput, CedarAnalysisReport,
14};
15use converge_pack::ProvenanceSource;
16
17use crate::delegation;
18use crate::engine::PolicyEngine;
19use crate::primitives::{Confidence, CostUsd, ProposalCount, ProposalLimit};
20use crate::provenance::{ARBITER_PROVENANCE, Arbiter};
21use crate::types::DecideRequest;
22
23const PROVENANCE_SOURCE: Arbiter = ARBITER_PROVENANCE;
24const POLICY_GATE_NAME: &str = "policy-gate";
25const DELEGATION_VERIFY_NAME: &str = "delegation-verify";
26const CEDAR_HITL_GATE_NAME: &str = "cedar-hitl-gate";
27const FLOW_GATE_NAME: &str = "flow-gate";
28const RATE_LIMIT_GATE_NAME: &str = "rate-limit-gate";
29const BUDGET_GATE_NAME: &str = "budget-gate";
30const APPROVAL_GATE_NAME: &str = "approval-gate";
31const DATA_CLASSIFICATION_GATE_NAME: &str = "data-classification-gate";
32const COMPLIANCE_GATE_NAME: &str = "compliance-gate";
33#[cfg(feature = "analysis")]
34const CEDAR_ANALYSIS_NAME: &str = "cedar-analysis";
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum GateConstraintAction {
39    Block,
40    Pause,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum ApprovalGateStatus {
46    PendingHumanReview,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct CostEstimatePayload {
52    pub cost: CostUsd,
53}
54
55impl FactPayload for CostEstimatePayload {
56    const FAMILY: &'static str = "arbiter.cost_estimate";
57    const VERSION: u16 = 1;
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct ApprovalRiskPayload {
63    pub confidence: Confidence,
64}
65
66impl FactPayload for ApprovalRiskPayload {
67    const FAMILY: &'static str = "arbiter.approval_risk";
68    const VERSION: u16 = 1;
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct ComplianceDocumentPayload {
74    pub fields: serde_json::Map<String, serde_json::Value>,
75}
76
77impl FactPayload for ComplianceDocumentPayload {
78    const FAMILY: &'static str = "arbiter.compliance_document";
79    const VERSION: u16 = 1;
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct DelegationVerificationPayload {
85    pub valid: bool,
86    pub reason: Option<String>,
87}
88
89impl FactPayload for DelegationVerificationPayload {
90    const FAMILY: &'static str = "arbiter.delegation_verification";
91    const VERSION: u16 = 1;
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct RateLimitConstraintPayload {
97    pub key: ContextKey,
98    pub count: ProposalCount,
99    pub limit: ProposalLimit,
100    pub action: GateConstraintAction,
101}
102
103impl FactPayload for RateLimitConstraintPayload {
104    const FAMILY: &'static str = "arbiter.constraint.rate_limit";
105    const VERSION: u16 = 1;
106}
107
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct BudgetConstraintPayload {
111    pub total_cost: CostUsd,
112    pub limit: CostUsd,
113    pub action: GateConstraintAction,
114}
115
116impl FactPayload for BudgetConstraintPayload {
117    const FAMILY: &'static str = "arbiter.constraint.budget";
118    const VERSION: u16 = 1;
119}
120
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct ApprovalConstraintPayload {
124    pub status: ApprovalGateStatus,
125    pub threshold: Confidence,
126    pub action: GateConstraintAction,
127}
128
129impl FactPayload for ApprovalConstraintPayload {
130    const FAMILY: &'static str = "arbiter.constraint.approval";
131    const VERSION: u16 = 1;
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(deny_unknown_fields)]
136pub struct DataClassificationConstraintPayload {
137    pub fact_id: FactId,
138    pub detected_types: Vec<String>,
139    pub action: GateConstraintAction,
140}
141
142impl FactPayload for DataClassificationConstraintPayload {
143    const FAMILY: &'static str = "arbiter.constraint.data_classification";
144    const VERSION: u16 = 1;
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct ComplianceConstraintPayload {
150    pub rule_id: String,
151    pub framework: String,
152    pub fact_id: FactId,
153    pub field: String,
154    pub action: GateConstraintAction,
155}
156
157impl FactPayload for ComplianceConstraintPayload {
158    const FAMILY: &'static str = "arbiter.constraint.compliance";
159    const VERSION: u16 = 1;
160}
161
162fn proposed_fact(
163    key: ContextKey,
164    id: impl Into<ProposalId>,
165    payload: impl FactPayload + PartialEq,
166) -> ProposedFact {
167    PROVENANCE_SOURCE.proposed_fact(key, id, payload)
168}
169
170fn proposed_fact_for(
171    source: &ContextFact,
172    key: ContextKey,
173    id: impl Into<ProposalId>,
174    payload: impl FactPayload + PartialEq,
175) -> ProposedFact {
176    PROVENANCE_SOURCE.proposed_fact_for(source, key, id, payload)
177}
178
179#[cfg(feature = "analysis")]
180fn diagnostic_for(
181    source: &ContextFact,
182    id: impl Into<ProposalId>,
183    message: impl Into<String>,
184) -> ProposedFact {
185    proposed_fact_for(
186        source,
187        ContextKey::Diagnostic,
188        id,
189        DiagnosticPayload::new("arbiter", message.into()),
190    )
191}
192
193#[cfg(feature = "analysis")]
194fn analysis_confidence(report: &CedarAnalysisReport) -> f64 {
195    match report.status {
196        CedarAnalysisExecutionStatus::NoViolation
197        | CedarAnalysisExecutionStatus::CounterexampleFound => 0.9,
198        CedarAnalysisExecutionStatus::Unknown => 0.2,
199        CedarAnalysisExecutionStatus::Error => 0.0,
200    }
201}
202
203// --- CedarAnalysisSuggestor ---
204
205#[cfg(feature = "analysis")]
206pub struct CedarAnalysisSuggestor<B> {
207    backend: B,
208    input_key: ContextKey,
209    output_key: ContextKey,
210}
211
212#[cfg(feature = "analysis")]
213impl<B> CedarAnalysisSuggestor<B>
214where
215    B: CedarAnalysisBackend,
216{
217    #[must_use]
218    pub fn new(backend: B) -> Self {
219        Self {
220            backend,
221            input_key: ContextKey::Seeds,
222            output_key: ContextKey::Evaluations,
223        }
224    }
225
226    #[must_use]
227    pub fn with_keys(mut self, input_key: ContextKey, output_key: ContextKey) -> Self {
228        self.input_key = input_key;
229        self.output_key = output_key;
230        self
231    }
232}
233
234#[cfg(feature = "analysis")]
235#[async_trait::async_trait]
236impl<B> Suggestor for CedarAnalysisSuggestor<B>
237where
238    B: CedarAnalysisBackend + 'static,
239{
240    fn name(&self) -> &'static str {
241        CEDAR_ANALYSIS_NAME
242    }
243
244    fn dependencies(&self) -> &[ContextKey] {
245        std::slice::from_ref(&self.input_key)
246    }
247
248    fn accepts(&self, ctx: &dyn Context) -> bool {
249        ctx.has(self.input_key) && !ctx.has(self.output_key)
250    }
251
252    fn provenance(&self) -> Provenance {
253        Provenance::from(ARBITER_PROVENANCE.as_str())
254    }
255
256    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
257        let mut proposals = Vec::new();
258        for fact in ctx.get(self.input_key) {
259            let input = match fact.require_payload::<CedarAnalysisInput>() {
260                Ok(input) => input,
261                Err(err) => {
262                    proposals.push(diagnostic_for(
263                        fact,
264                        format!("cedar-analysis-parse-error-{}", fact.id()),
265                        format!("expected CedarAnalysisInput payload: {err}"),
266                    ));
267                    continue;
268                }
269            };
270
271            match self.backend.analyze(&input).await {
272                Ok(report) => proposals.push(
273                    proposed_fact_for(
274                        fact,
275                        self.output_key,
276                        format!("cedar-analysis-report-{}", report.plan.invariant_id),
277                        report.clone(),
278                    )
279                    .with_confidence(analysis_confidence(&report)),
280                ),
281                Err(err) => proposals.push(diagnostic_for(
282                    fact,
283                    format!("cedar-analysis-backend-error-{}", input.invariant_id),
284                    format!(
285                        "Cedar Analysis backend {} failed: {err}",
286                        self.backend.name()
287                    ),
288                )),
289            }
290        }
291
292        AgentEffect::with_proposals(proposals)
293    }
294}
295
296// --- PolicyGateSuggestor ---
297
298pub struct PolicyGateSuggestor {
299    engine: Arc<PolicyEngine>,
300    input_key: ContextKey,
301    output_key: ContextKey,
302}
303
304impl PolicyGateSuggestor {
305    #[must_use]
306    pub fn new(engine: Arc<PolicyEngine>) -> Self {
307        Self {
308            engine,
309            input_key: ContextKey::Seeds,
310            output_key: ContextKey::Constraints,
311        }
312    }
313
314    #[must_use]
315    pub fn with_keys(
316        engine: Arc<PolicyEngine>,
317        input_key: ContextKey,
318        output_key: ContextKey,
319    ) -> Self {
320        Self {
321            engine,
322            input_key,
323            output_key,
324        }
325    }
326}
327
328#[async_trait::async_trait]
329impl Suggestor for PolicyGateSuggestor {
330    fn name(&self) -> &'static str {
331        POLICY_GATE_NAME
332    }
333
334    fn dependencies(&self) -> &[ContextKey] {
335        std::slice::from_ref(&self.input_key)
336    }
337
338    fn accepts(&self, ctx: &dyn Context) -> bool {
339        ctx.has(self.input_key) && !ctx.has(self.output_key)
340    }
341
342    fn provenance(&self) -> Provenance {
343        Provenance::from(ARBITER_PROVENANCE.as_str())
344    }
345
346    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
347        let facts = ctx.get(self.input_key);
348        let Some(seed) = facts.first() else {
349            return AgentEffect::empty();
350        };
351
352        let req: DecideRequest = match seed.require_payload::<DecideRequest>() {
353            Ok(r) => r.clone(),
354            Err(e) => {
355                let diag = proposed_fact_for(
356                    seed,
357                    ContextKey::Diagnostic,
358                    "policy-gate-error",
359                    DiagnosticPayload::new(
360                        "arbiter",
361                        format!("expected DecideRequest payload: {e}"),
362                    ),
363                );
364                return AgentEffect::with_proposal(diag);
365            }
366        };
367
368        match self.engine.evaluate(&req) {
369            Ok(decision) => {
370                let proposal =
371                    proposed_fact_for(seed, self.output_key, "policy-decision", decision);
372                AgentEffect::with_proposal(proposal)
373            }
374            Err(e) => {
375                let diag = proposed_fact_for(
376                    seed,
377                    ContextKey::Diagnostic,
378                    "policy-gate-error",
379                    DiagnosticPayload::new("arbiter", format!("policy evaluation failed: {e}")),
380                );
381                AgentEffect::with_proposal(diag)
382            }
383        }
384    }
385}
386
387// --- DelegationVerifySuggestor ---
388
389#[allow(clippy::struct_field_names)]
390pub struct DelegationVerifySuggestor {
391    verifying_key: VerifyingKey,
392    input_key: ContextKey,
393    output_key: ContextKey,
394}
395
396impl DelegationVerifySuggestor {
397    #[must_use]
398    pub fn new(verifying_key: VerifyingKey) -> Self {
399        Self {
400            verifying_key,
401            input_key: ContextKey::Seeds,
402            output_key: ContextKey::Constraints,
403        }
404    }
405
406    #[must_use]
407    pub fn with_keys(
408        verifying_key: VerifyingKey,
409        input_key: ContextKey,
410        output_key: ContextKey,
411    ) -> Self {
412        Self {
413            verifying_key,
414            input_key,
415            output_key,
416        }
417    }
418}
419
420#[async_trait::async_trait]
421impl Suggestor for DelegationVerifySuggestor {
422    fn name(&self) -> &'static str {
423        DELEGATION_VERIFY_NAME
424    }
425
426    fn dependencies(&self) -> &[ContextKey] {
427        std::slice::from_ref(&self.input_key)
428    }
429
430    fn accepts(&self, ctx: &dyn Context) -> bool {
431        ctx.has(self.input_key) && !ctx.has(self.output_key)
432    }
433
434    fn provenance(&self) -> Provenance {
435        Provenance::from(ARBITER_PROVENANCE.as_str())
436    }
437
438    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
439        let facts = ctx.get(self.input_key);
440        let Some(seed) = facts.first() else {
441            return AgentEffect::empty();
442        };
443
444        let req: DecideRequest = match seed.require_payload::<DecideRequest>() {
445            Ok(r) => r.clone(),
446            Err(e) => {
447                let diag = proposed_fact_for(
448                    seed,
449                    ContextKey::Diagnostic,
450                    "delegation-verify-error",
451                    DiagnosticPayload::new(
452                        "arbiter",
453                        format!("expected DecideRequest payload: {e}"),
454                    ),
455                );
456                return AgentEffect::with_proposal(diag);
457            }
458        };
459
460        let Some(ref token_b64) = req.delegation_b64 else {
461            let diag = proposed_fact_for(
462                seed,
463                ContextKey::Diagnostic,
464                "delegation-verify-error",
465                DiagnosticPayload::new("arbiter", "no delegation_b64 in request"),
466            );
467            return AgentEffect::with_proposal(diag);
468        };
469
470        match delegation::verify(token_b64, &self.verifying_key, &req) {
471            Ok(valid) => {
472                let proposal = proposed_fact_for(
473                    seed,
474                    self.output_key,
475                    "delegation-result",
476                    DelegationVerificationPayload {
477                        valid,
478                        reason: (!valid).then(|| "constraints not met".to_string()),
479                    },
480                );
481                AgentEffect::with_proposal(proposal)
482            }
483            Err(e) => {
484                let proposal = proposed_fact_for(
485                    seed,
486                    self.output_key,
487                    "delegation-result",
488                    DelegationVerificationPayload {
489                        valid: false,
490                        reason: Some(e),
491                    },
492                );
493                AgentEffect::with_proposal(proposal)
494            }
495        }
496    }
497}
498
499fn execute_flow_gate(
500    engine: &PolicyEngine,
501    input_key: ContextKey,
502    output_key: ContextKey,
503    proposal_id: &'static str,
504    error_id: &'static str,
505    ctx: &dyn Context,
506) -> AgentEffect {
507    let facts = ctx.get(input_key);
508    let Some(seed) = facts.first() else {
509        return AgentEffect::empty();
510    };
511
512    let input: FlowGateInput = match seed.require_payload::<FlowGateInput>() {
513        Ok(i) => i.clone(),
514        Err(e) => {
515            let diag = proposed_fact_for(
516                seed,
517                ContextKey::Diagnostic,
518                error_id,
519                DiagnosticPayload::new("arbiter", format!("expected FlowGateInput payload: {e}")),
520            );
521            return AgentEffect::with_proposal(diag);
522        }
523    };
524
525    match engine.evaluate_flow(&input) {
526        Ok(decision) => {
527            let proposal = proposed_fact_for(seed, output_key, proposal_id, decision);
528            AgentEffect::with_proposal(proposal)
529        }
530        Err(e) => {
531            let diag = proposed_fact_for(
532                seed,
533                ContextKey::Diagnostic,
534                error_id,
535                DiagnosticPayload::new("arbiter", format!("flow gate evaluation failed: {e}")),
536            );
537            AgentEffect::with_proposal(diag)
538        }
539    }
540}
541
542// --- CedarHitlGateSuggestor ---
543
544/// Named Cedar HITL gate surface for Formation registration.
545///
546/// This is intentionally the same Cedar flow authorization path as
547/// [`FlowGateSuggestor`], but exposed under a stricter, discoverable name for
548/// high-risk human-in-the-loop gates. Escalation is emitted only when Cedar
549/// denies the original request and allows the same request with
550/// `human_approval_present = true`.
551pub struct CedarHitlGateSuggestor {
552    engine: Arc<PolicyEngine>,
553    input_key: ContextKey,
554    output_key: ContextKey,
555}
556
557impl CedarHitlGateSuggestor {
558    #[must_use]
559    pub fn new(engine: Arc<PolicyEngine>) -> Self {
560        Self {
561            engine,
562            input_key: ContextKey::Seeds,
563            output_key: ContextKey::Constraints,
564        }
565    }
566
567    #[must_use]
568    pub fn with_keys(
569        engine: Arc<PolicyEngine>,
570        input_key: ContextKey,
571        output_key: ContextKey,
572    ) -> Self {
573        Self {
574            engine,
575            input_key,
576            output_key,
577        }
578    }
579}
580
581#[async_trait::async_trait]
582impl Suggestor for CedarHitlGateSuggestor {
583    fn name(&self) -> &'static str {
584        CEDAR_HITL_GATE_NAME
585    }
586
587    fn dependencies(&self) -> &[ContextKey] {
588        std::slice::from_ref(&self.input_key)
589    }
590
591    fn accepts(&self, ctx: &dyn Context) -> bool {
592        ctx.has(self.input_key) && !ctx.has(self.output_key)
593    }
594
595    fn provenance(&self) -> Provenance {
596        Provenance::from(ARBITER_PROVENANCE.as_str())
597    }
598
599    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
600        execute_flow_gate(
601            self.engine.as_ref(),
602            self.input_key,
603            self.output_key,
604            "cedar-hitl-gate-decision",
605            "cedar-hitl-gate-error",
606            ctx,
607        )
608    }
609}
610
611// --- FlowGateSuggestor ---
612
613pub struct FlowGateSuggestor {
614    engine: Arc<PolicyEngine>,
615    input_key: ContextKey,
616    output_key: ContextKey,
617}
618
619impl FlowGateSuggestor {
620    #[must_use]
621    pub fn new(engine: Arc<PolicyEngine>) -> Self {
622        Self {
623            engine,
624            input_key: ContextKey::Seeds,
625            output_key: ContextKey::Constraints,
626        }
627    }
628
629    #[must_use]
630    pub fn with_keys(
631        engine: Arc<PolicyEngine>,
632        input_key: ContextKey,
633        output_key: ContextKey,
634    ) -> Self {
635        Self {
636            engine,
637            input_key,
638            output_key,
639        }
640    }
641}
642
643#[async_trait::async_trait]
644impl Suggestor for FlowGateSuggestor {
645    fn name(&self) -> &'static str {
646        FLOW_GATE_NAME
647    }
648
649    fn dependencies(&self) -> &[ContextKey] {
650        std::slice::from_ref(&self.input_key)
651    }
652
653    fn accepts(&self, ctx: &dyn Context) -> bool {
654        ctx.has(self.input_key) && !ctx.has(self.output_key)
655    }
656
657    fn provenance(&self) -> Provenance {
658        Provenance::from(ARBITER_PROVENANCE.as_str())
659    }
660
661    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
662        execute_flow_gate(
663            self.engine.as_ref(),
664            self.input_key,
665            self.output_key,
666            "flow-gate-decision",
667            "flow-gate-error",
668            ctx,
669        )
670    }
671}
672
673// --- RateLimitGateSuggestor ---
674
675/// Throttles agent activity by counting proposals per key per convergence run.
676/// If the count exceeds the limit, emits a constraint blocking further proposals.
677pub struct RateLimitGateSuggestor {
678    max_proposals_per_key: usize,
679    watched_key: ContextKey,
680}
681
682impl RateLimitGateSuggestor {
683    #[must_use]
684    pub fn new(watched_key: ContextKey, max_proposals_per_key: usize) -> Self {
685        Self {
686            max_proposals_per_key,
687            watched_key,
688        }
689    }
690}
691
692#[async_trait::async_trait]
693impl Suggestor for RateLimitGateSuggestor {
694    fn name(&self) -> &'static str {
695        RATE_LIMIT_GATE_NAME
696    }
697
698    fn dependencies(&self) -> &[ContextKey] {
699        std::slice::from_ref(&self.watched_key)
700    }
701
702    fn accepts(&self, ctx: &dyn Context) -> bool {
703        ctx.count(self.watched_key) > self.max_proposals_per_key
704            && !ctx
705                .get(ContextKey::Constraints)
706                .iter()
707                .any(|f| f.id() == "rate-limit-exceeded")
708    }
709
710    fn provenance(&self) -> Provenance {
711        Provenance::from(ARBITER_PROVENANCE.as_str())
712    }
713
714    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
715        let count = ctx.count(self.watched_key);
716        AgentEffect::with_proposal(proposed_fact(
717            ContextKey::Constraints,
718            "rate-limit-exceeded",
719            RateLimitConstraintPayload {
720                key: self.watched_key,
721                count: ProposalCount(count),
722                limit: ProposalLimit(self.max_proposals_per_key),
723                action: GateConstraintAction::Block,
724            },
725        ))
726    }
727}
728
729// --- BudgetGateSuggestor ---
730
731/// Enforces a cost/token budget within a convergence run.
732/// Reads cost estimates from proposals and blocks when cumulative cost exceeds the limit.
733pub struct BudgetGateSuggestor {
734    max_cost: CostUsd,
735    cost_key: ContextKey,
736}
737
738impl BudgetGateSuggestor {
739    #[must_use]
740    pub fn new(cost_key: ContextKey, max_cost: f64) -> Self {
741        Self {
742            max_cost: CostUsd::clamped(max_cost),
743            cost_key,
744        }
745    }
746}
747
748#[async_trait::async_trait]
749impl Suggestor for BudgetGateSuggestor {
750    fn name(&self) -> &'static str {
751        BUDGET_GATE_NAME
752    }
753
754    fn dependencies(&self) -> &[ContextKey] {
755        std::slice::from_ref(&self.cost_key)
756    }
757
758    fn accepts(&self, ctx: &dyn Context) -> bool {
759        ctx.has(self.cost_key)
760            && !ctx
761                .get(ContextKey::Constraints)
762                .iter()
763                .any(|f| f.id() == "budget-exceeded")
764    }
765
766    fn provenance(&self) -> Provenance {
767        Provenance::from(ARBITER_PROVENANCE.as_str())
768    }
769
770    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
771        let facts = ctx.get(self.cost_key);
772        let total_raw: f64 = facts
773            .iter()
774            .filter_map(|fact| {
775                fact.payload::<CostEstimatePayload>()
776                    .map(|payload| payload.cost.value())
777            })
778            .sum();
779        let total_cost = CostUsd::clamped(total_raw);
780
781        if total_cost > self.max_cost {
782            AgentEffect::with_proposal(proposed_fact(
783                ContextKey::Constraints,
784                "budget-exceeded",
785                BudgetConstraintPayload {
786                    total_cost,
787                    limit: self.max_cost,
788                    action: GateConstraintAction::Block,
789                },
790            ))
791        } else {
792            AgentEffect::empty()
793        }
794    }
795}
796
797// --- ApprovalGateSuggestor ---
798
799/// Requires human-in-the-loop approval for high-stakes proposals.
800/// Blocks proposals matching a predicate until an approval fact appears.
801pub struct ApprovalGateSuggestor {
802    watched_key: ContextKey,
803    approval_key: ContextKey,
804    stakes_threshold: Confidence,
805}
806
807impl ApprovalGateSuggestor {
808    /// Gate proposals on `watched_key` that have confidence above `stakes_threshold`.
809    /// Approval is signaled by a fact in `approval_key`.
810    #[must_use]
811    pub fn new(watched_key: ContextKey, stakes_threshold: f64) -> Self {
812        Self {
813            watched_key,
814            approval_key: ContextKey::Signals,
815            stakes_threshold: Confidence::clamped(stakes_threshold),
816        }
817    }
818
819    #[must_use]
820    pub fn with_approval_key(mut self, key: ContextKey) -> Self {
821        self.approval_key = key;
822        self
823    }
824}
825
826#[async_trait::async_trait]
827impl Suggestor for ApprovalGateSuggestor {
828    fn name(&self) -> &'static str {
829        APPROVAL_GATE_NAME
830    }
831
832    fn dependencies(&self) -> &[ContextKey] {
833        std::slice::from_ref(&self.watched_key)
834    }
835
836    fn accepts(&self, ctx: &dyn Context) -> bool {
837        ctx.has(self.watched_key)
838            && !ctx.has(self.approval_key)
839            && !ctx
840                .get(ContextKey::Constraints)
841                .iter()
842                .any(|f| f.id() == "approval-pending")
843    }
844
845    fn provenance(&self) -> Provenance {
846        Provenance::from(ARBITER_PROVENANCE.as_str())
847    }
848
849    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
850        let facts = ctx.get(self.watched_key);
851        let needs_approval = facts.iter().find(|f| {
852            f.payload::<ApprovalRiskPayload>()
853                .is_none_or(|payload| payload.confidence >= self.stakes_threshold)
854        });
855
856        if let Some(source) = needs_approval {
857            AgentEffect::with_proposal(proposed_fact_for(
858                source,
859                ContextKey::Constraints,
860                "approval-pending",
861                ApprovalConstraintPayload {
862                    status: ApprovalGateStatus::PendingHumanReview,
863                    threshold: self.stakes_threshold,
864                    action: GateConstraintAction::Pause,
865                },
866            ))
867        } else {
868            AgentEffect::empty()
869        }
870    }
871}
872
873// --- DataClassificationGateSuggestor ---
874
875/// Default PII patterns compiled once at process start.
876///
877/// `expect()` inside `LazyLock::new` is acceptable: it fires exactly once on first access
878/// (at process start, not per-request), so it cannot cause per-request panics.
879static DEFAULT_PII_PATTERNS: LazyLock<Vec<(&'static str, regex::Regex)>> = LazyLock::new(|| {
880    vec![
881        (
882            "email",
883            regex::Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
884                .expect("email regex"),
885        ),
886        (
887            "ssn",
888            regex::Regex::new(r"\b\d{3}-\d{2}-\d{4}\b").expect("SSN regex"),
889        ),
890        (
891            "credit_card",
892            regex::Regex::new(r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b")
893                .expect("credit-card regex"),
894        ),
895        (
896            "phone",
897            regex::Regex::new(r"\b\+?\d{1,3}[-.\s]?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b")
898                .expect("phone regex"),
899        ),
900    ]
901});
902
903/// Blocks proposals containing PII or sensitive data patterns from crossing boundaries.
904/// Scans proposal content for configurable patterns (emails, SSNs, credit cards, etc.).
905pub struct DataClassificationGateSuggestor {
906    watched_key: ContextKey,
907    patterns: Vec<(&'static str, regex::Regex)>,
908}
909
910impl DataClassificationGateSuggestor {
911    /// Create with default PII patterns (email, SSN, credit card, phone).
912    ///
913    /// Patterns are compiled once via [`DEFAULT_PII_PATTERNS`] and cloned here,
914    /// so construction is cheap after the first call.
915    #[must_use]
916    pub fn default_patterns(watched_key: ContextKey) -> Self {
917        Self {
918            watched_key,
919            patterns: DEFAULT_PII_PATTERNS.clone(),
920        }
921    }
922}
923
924#[async_trait::async_trait]
925impl Suggestor for DataClassificationGateSuggestor {
926    fn name(&self) -> &'static str {
927        DATA_CLASSIFICATION_GATE_NAME
928    }
929
930    fn dependencies(&self) -> &[ContextKey] {
931        std::slice::from_ref(&self.watched_key)
932    }
933
934    fn accepts(&self, ctx: &dyn Context) -> bool {
935        ctx.has(self.watched_key)
936            && !ctx
937                .get(ContextKey::Constraints)
938                .iter()
939                .any(|f| f.id().as_str().starts_with("pii-detected-"))
940    }
941
942    fn provenance(&self) -> Provenance {
943        Provenance::from(ARBITER_PROVENANCE.as_str())
944    }
945
946    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
947        let facts = ctx.get(self.watched_key);
948        let mut proposals = Vec::new();
949
950        for fact in facts {
951            let mut detected = Vec::new();
952            for (label, pattern) in &self.patterns {
953                if fact.text().is_some_and(|content| pattern.is_match(content)) {
954                    detected.push(*label);
955                }
956            }
957            if !detected.is_empty() {
958                proposals.push(proposed_fact_for(
959                    fact,
960                    ContextKey::Constraints,
961                    format!("pii-detected-{}", fact.id()),
962                    DataClassificationConstraintPayload {
963                        fact_id: fact.id().clone(),
964                        detected_types: detected.into_iter().map(str::to_string).collect(),
965                        action: GateConstraintAction::Block,
966                    },
967                ));
968            }
969        }
970
971        AgentEffect::with_proposals(proposals)
972    }
973}
974
975// --- ComplianceGateSuggestor ---
976
977/// Checks proposals against compliance requirements (GDPR, SOC2, HIPAA, etc.).
978/// Configurable with compliance rules that map to constraint violations.
979pub struct ComplianceGateSuggestor {
980    watched_key: ContextKey,
981    rules: Vec<ComplianceRule>,
982}
983
984/// A compliance rule that checks a condition and produces a violation if met.
985pub struct ComplianceRule {
986    /// Rule identifier (e.g., "gdpr-data-retention").
987    pub id: String,
988    /// Compliance framework (e.g., "GDPR", "SOC2", "HIPAA").
989    pub framework: String,
990    /// JSON path to check in proposal content.
991    pub field: String,
992    /// Condition that triggers a violation.
993    pub condition: ComplianceCondition,
994}
995
996/// What triggers a compliance violation.
997///
998/// The original three variants (`FieldMustNotExist`, `MaxValue`,
999/// `MustNotContain`) cover the most common rule shapes. The
1000/// remaining four cover the cases that come up once you start
1001/// authoring rules from real regulatory text:
1002///
1003/// - `RegexMatch` — when the violation is a pattern (PCI card
1004///   prefixes, OFAC name patterns, classification tag formats).
1005/// - `NumericRange` — when both a floor and a ceiling matter
1006///   (transaction amounts under a reporting threshold AND above a
1007///   minimum-fee filter).
1008/// - `CrossField` — when the violation requires a *combination* of
1009///   field values (e.g. `data_classification == "PHI" → encryption
1010///   == true`). A single-field rule cannot express this.
1011/// - `MembershipInVersionedList` — when the violation is membership
1012///   in a sanctions / blocklist / quarantine list that is versioned
1013///   externally (OFAC publishes daily updates). Pinning the list
1014///   version is required so an audit log can be reproduced months
1015///   later against the same list state.
1016pub enum ComplianceCondition {
1017    /// Field must not be present.
1018    FieldMustNotExist,
1019    /// Field value must not exceed this numeric threshold.
1020    MaxValue(f64),
1021    /// Field value must not contain any of these substrings.
1022    MustNotContain(Vec<String>),
1023    /// Field value (string) must not match this regex.
1024    RegexMatch(String),
1025    /// Field value (numeric) must lie within `[lo, hi]` (inclusive).
1026    /// A value outside the range is a violation.
1027    NumericRange { lo: f64, hi: f64 },
1028    /// If `antecedent_field == antecedent_value`, then
1029    /// `consequent_field` must equal `consequent_value`. A document
1030    /// where the antecedent holds but the consequent does not is a
1031    /// violation. Encodes single-rule cross-field implications
1032    /// without requiring composite rules.
1033    CrossField {
1034        antecedent_field: String,
1035        antecedent_value: String,
1036        consequent_field: String,
1037        consequent_value: String,
1038    },
1039    /// Field value (string) must not be a member of a versioned
1040    /// external list. The `list_id` + `version` pair pins the
1041    /// snapshot of the list this rule was authored against, so
1042    /// audit reproducibility is preserved when the list is later
1043    /// updated by its publisher.
1044    MembershipInVersionedList {
1045        list_id: String,
1046        version: String,
1047        members: Vec<String>,
1048    },
1049}
1050
1051impl ComplianceGateSuggestor {
1052    #[must_use]
1053    pub fn new(watched_key: ContextKey, rules: Vec<ComplianceRule>) -> Self {
1054        Self { watched_key, rules }
1055    }
1056}
1057
1058#[async_trait::async_trait]
1059impl Suggestor for ComplianceGateSuggestor {
1060    fn name(&self) -> &'static str {
1061        COMPLIANCE_GATE_NAME
1062    }
1063
1064    fn dependencies(&self) -> &[ContextKey] {
1065        std::slice::from_ref(&self.watched_key)
1066    }
1067
1068    fn accepts(&self, ctx: &dyn Context) -> bool {
1069        ctx.has(self.watched_key)
1070            && !ctx
1071                .get(ContextKey::Constraints)
1072                .iter()
1073                .any(|f| f.id().as_str().starts_with("compliance-"))
1074    }
1075
1076    fn provenance(&self) -> Provenance {
1077        Provenance::from(ARBITER_PROVENANCE.as_str())
1078    }
1079
1080    async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
1081        let facts = ctx.get(self.watched_key);
1082        let mut proposals = Vec::new();
1083
1084        for fact in facts {
1085            let Some(value) = fact.payload::<ComplianceDocumentPayload>() else {
1086                continue;
1087            };
1088
1089            for rule in &self.rules {
1090                let violated = match &rule.condition {
1091                    ComplianceCondition::FieldMustNotExist => {
1092                        value.fields.get(&rule.field).is_some()
1093                    }
1094                    ComplianceCondition::MaxValue(max) => value
1095                        .fields
1096                        .get(&rule.field)
1097                        .and_then(serde_json::Value::as_f64)
1098                        .is_some_and(|v| v > *max),
1099                    ComplianceCondition::MustNotContain(forbidden) => value
1100                        .fields
1101                        .get(&rule.field)
1102                        .and_then(|v| v.as_str())
1103                        .is_some_and(|s| forbidden.iter().any(|f| s.contains(f.as_str()))),
1104                    ComplianceCondition::RegexMatch(pattern) => value
1105                        .fields
1106                        .get(&rule.field)
1107                        .and_then(|v| v.as_str())
1108                        .and_then(|s| regex::Regex::new(pattern).ok().map(|re| re.is_match(s)))
1109                        .unwrap_or(false),
1110                    ComplianceCondition::NumericRange { lo, hi } => value
1111                        .fields
1112                        .get(&rule.field)
1113                        .and_then(serde_json::Value::as_f64)
1114                        .is_some_and(|v| v < *lo || v > *hi),
1115                    ComplianceCondition::CrossField {
1116                        antecedent_field,
1117                        antecedent_value,
1118                        consequent_field,
1119                        consequent_value,
1120                    } => {
1121                        let antecedent_holds = value
1122                            .fields
1123                            .get(antecedent_field)
1124                            .and_then(|v| v.as_str())
1125                            .is_some_and(|s| s == antecedent_value);
1126                        if antecedent_holds {
1127                            let consequent_holds = value
1128                                .fields
1129                                .get(consequent_field)
1130                                .and_then(|v| v.as_str())
1131                                .is_some_and(|s| s == consequent_value);
1132                            !consequent_holds
1133                        } else {
1134                            false
1135                        }
1136                    }
1137                    ComplianceCondition::MembershipInVersionedList { members, .. } => value
1138                        .fields
1139                        .get(&rule.field)
1140                        .and_then(|v| v.as_str())
1141                        .is_some_and(|s| members.iter().any(|m| s == m.as_str())),
1142                };
1143
1144                if violated {
1145                    proposals.push(proposed_fact_for(
1146                        fact,
1147                        ContextKey::Constraints,
1148                        format!("compliance-{}-{}", rule.id, fact.id()),
1149                        ComplianceConstraintPayload {
1150                            rule_id: rule.id.clone(),
1151                            framework: rule.framework.clone(),
1152                            fact_id: fact.id().clone(),
1153                            field: rule.field.clone(),
1154                            action: GateConstraintAction::Block,
1155                        },
1156                    ));
1157                }
1158            }
1159        }
1160
1161        AgentEffect::with_proposals(proposals)
1162    }
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167    use super::*;
1168    use converge_core::{
1169        AuthorityLevel, FlowAction, FlowGateContext, FlowGatePrincipal, FlowGateResource, FlowPhase,
1170    };
1171    use converge_pack::{
1172        ContentHash, ContextFact, FactActor, FactActorKind, FactLocalTrace, FactPayload,
1173        FactPromotionRecord, FactTraceLink, FactValidationSummary, TextPayload, Timestamp,
1174    };
1175    use std::collections::HashMap;
1176
1177    #[cfg(feature = "analysis")]
1178    #[derive(Debug, Clone, Copy)]
1179    struct FixedAnalysisBackend {
1180        status: crate::analysis::CedarAnalysisExecutionStatus,
1181    }
1182
1183    #[cfg(feature = "analysis")]
1184    #[async_trait::async_trait]
1185    impl crate::analysis::CedarAnalysisBackend for FixedAnalysisBackend {
1186        fn name(&self) -> &'static str {
1187            "fixed-analysis"
1188        }
1189
1190        async fn analyze(
1191            &self,
1192            input: &crate::analysis::CedarAnalysisInput,
1193        ) -> Result<crate::analysis::CedarAnalysisReport, crate::analysis::CedarAnalysisError>
1194        {
1195            let plan = crate::analysis::compile_analysis_plan(input)?;
1196            Ok(crate::analysis::CedarAnalysisReport {
1197                plan,
1198                execution_identity: converge_pack::ExecutionIdentity::non_native(
1199                    env!("CARGO_PKG_NAME"),
1200                    env!("CARGO_PKG_VERSION"),
1201                    self.name(),
1202                    format!(
1203                        "invariant_id={}; status={:?}",
1204                        input.invariant_id, self.status
1205                    ),
1206                ),
1207                status: self.status,
1208                checks: Vec::new(),
1209            })
1210        }
1211    }
1212
1213    struct MockContext {
1214        facts: HashMap<ContextKey, Vec<ContextFact>>,
1215    }
1216
1217    impl MockContext {
1218        fn empty() -> Self {
1219            Self {
1220                facts: HashMap::new(),
1221            }
1222        }
1223    }
1224
1225    fn context_fact(
1226        key: ContextKey,
1227        id: impl Into<converge_pack::FactId>,
1228        payload: impl FactPayload + PartialEq,
1229    ) -> ContextFact {
1230        ContextFact::new_projection(
1231            key,
1232            id,
1233            payload,
1234            FactPromotionRecord::new_projection(
1235                "policy-test",
1236                ContentHash::zero(),
1237                FactActor::new_projection("policy-test", FactActorKind::System),
1238                FactValidationSummary::default(),
1239                Vec::new(),
1240                FactTraceLink::Local(FactLocalTrace::new_projection(
1241                    "policy-test",
1242                    "policy-test",
1243                    None,
1244                    true,
1245                )),
1246                Timestamp::epoch(),
1247            ),
1248            Timestamp::epoch(),
1249        )
1250    }
1251
1252    impl Context for MockContext {
1253        fn has(&self, key: ContextKey) -> bool {
1254            self.facts.get(&key).is_some_and(|v| !v.is_empty())
1255        }
1256
1257        fn get(&self, key: ContextKey) -> &[ContextFact] {
1258            self.facts.get(&key).map_or(&[], Vec::as_slice)
1259        }
1260    }
1261
1262    #[test]
1263    fn policy_gate_name() {
1264        let engine = Arc::new(
1265            PolicyEngine::from_policy_str("permit(principal, action, resource);").unwrap(),
1266        );
1267        let s = PolicyGateSuggestor::new(engine);
1268        assert_eq!(s.name(), "policy-gate");
1269    }
1270
1271    #[test]
1272    fn policy_gate_dependencies() {
1273        let engine = Arc::new(
1274            PolicyEngine::from_policy_str("permit(principal, action, resource);").unwrap(),
1275        );
1276        let s = PolicyGateSuggestor::new(engine);
1277        assert_eq!(s.dependencies(), &[ContextKey::Seeds]);
1278    }
1279
1280    #[test]
1281    fn policy_gate_rejects_empty_context() {
1282        let engine = Arc::new(
1283            PolicyEngine::from_policy_str("permit(principal, action, resource);").unwrap(),
1284        );
1285        let s = PolicyGateSuggestor::new(engine);
1286        let ctx = MockContext::empty();
1287        assert!(!s.accepts(&ctx));
1288    }
1289
1290    #[test]
1291    fn delegation_verify_name() {
1292        let key = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]).verifying_key();
1293        let s = DelegationVerifySuggestor::new(key);
1294        assert_eq!(s.name(), "delegation-verify");
1295    }
1296
1297    #[test]
1298    fn flow_gate_name() {
1299        let engine = Arc::new(
1300            PolicyEngine::from_policy_str("permit(principal, action, resource);").unwrap(),
1301        );
1302        let s = FlowGateSuggestor::new(engine);
1303        assert_eq!(s.name(), "flow-gate");
1304    }
1305
1306    #[test]
1307    fn cedar_hitl_gate_name_and_deps() {
1308        let engine = Arc::new(
1309            PolicyEngine::from_policy_str("permit(principal, action, resource);").unwrap(),
1310        );
1311        let s = CedarHitlGateSuggestor::new(engine);
1312        assert_eq!(s.name(), "cedar-hitl-gate");
1313        assert_eq!(s.dependencies(), &[ContextKey::Seeds]);
1314    }
1315
1316    #[cfg(feature = "analysis")]
1317    #[test]
1318    fn cedar_analysis_suggestor_name_and_deps() {
1319        let s = CedarAnalysisSuggestor::new(FixedAnalysisBackend {
1320            status: crate::analysis::CedarAnalysisExecutionStatus::NoViolation,
1321        });
1322
1323        assert_eq!(s.name(), "cedar-analysis");
1324        assert_eq!(s.dependencies(), &[ContextKey::Seeds]);
1325    }
1326
1327    #[test]
1328    fn flow_gate_rejects_empty_context() {
1329        let engine = Arc::new(
1330            PolicyEngine::from_policy_str("permit(principal, action, resource);").unwrap(),
1331        );
1332        let s = FlowGateSuggestor::new(engine);
1333        let ctx = MockContext::empty();
1334        assert!(!s.accepts(&ctx));
1335    }
1336
1337    #[tokio::test]
1338    async fn cedar_hitl_gate_emits_strict_escalation_decision() {
1339        let policy = r#"
1340            permit(principal, action == Action::"commit", resource)
1341            when { context.human_approval_present == true };
1342        "#;
1343        let engine = Arc::new(PolicyEngine::from_policy_str(policy).unwrap());
1344        let s = CedarHitlGateSuggestor::new(engine);
1345        let input = FlowGateInput {
1346            principal: FlowGatePrincipal {
1347                id: "agent:finance".into(),
1348                authority: AuthorityLevel::Supervisory,
1349                domains: vec!["finance".into()],
1350                policy_version: Some("expense_v1".into()),
1351            },
1352            resource: FlowGateResource {
1353                id: "expense:001".into(),
1354                kind: "expense".into(),
1355                phase: FlowPhase::Commitment,
1356                gates_passed: vec!["receipt".into()],
1357            },
1358            action: FlowAction::Commit,
1359            context: FlowGateContext {
1360                commitment_type: Some("expense".into()),
1361                amount: Some(1_250),
1362                human_approval_present: Some(false),
1363                required_gates_met: Some(true),
1364            },
1365        };
1366
1367        let mut ctx = MockContext::empty();
1368        ctx.facts.insert(
1369            ContextKey::Seeds,
1370            vec![context_fact(ContextKey::Seeds, "flow-gate-input", input)],
1371        );
1372
1373        assert!(s.accepts(&ctx));
1374        let effect = s.execute(&ctx).await;
1375        assert_eq!(effect.proposals().len(), 1);
1376        assert!(
1377            effect.proposals()[0]
1378                .id
1379                .contains("cedar-hitl-gate-decision")
1380        );
1381
1382        let decision = effect.proposals()[0]
1383            .require_payload::<crate::PolicyDecision>()
1384            .unwrap();
1385        assert_eq!(decision.outcome, crate::PolicyOutcome::Escalate);
1386    }
1387
1388    #[cfg(feature = "analysis")]
1389    #[tokio::test]
1390    async fn cedar_analysis_suggestor_emits_searched_report() {
1391        let s = CedarAnalysisSuggestor::new(FixedAnalysisBackend {
1392            status: crate::analysis::CedarAnalysisExecutionStatus::NoViolation,
1393        });
1394        let input = crate::analysis::CedarAnalysisInput::new(
1395            "expense.non_finance_commit.high_value",
1396            crate::analysis::CedarAnalysisQuery::ExpenseNonFinanceHighValueCommitDenied,
1397            crate::EXPENSE_APPROVAL_POLICY,
1398            crate::EXPENSE_APPROVAL_SCHEMA,
1399        );
1400
1401        let mut ctx = MockContext::empty();
1402        ctx.facts.insert(
1403            ContextKey::Seeds,
1404            vec![context_fact(
1405                ContextKey::Seeds,
1406                "cedar-analysis-input",
1407                input,
1408            )],
1409        );
1410
1411        assert!(s.accepts(&ctx));
1412        let effect = s.execute(&ctx).await;
1413
1414        assert_eq!(effect.proposals().len(), 1);
1415        assert_eq!(effect.proposals()[0].key, ContextKey::Evaluations);
1416        assert_eq!(effect.proposals()[0].provenance(), "arbiter");
1417        let report = effect.proposals()[0]
1418            .require_payload::<crate::analysis::CedarAnalysisReport>()
1419            .unwrap();
1420        assert_eq!(
1421            report.status,
1422            crate::analysis::CedarAnalysisExecutionStatus::NoViolation
1423        );
1424        assert_eq!(
1425            report.plan.query,
1426            crate::analysis::CedarAnalysisQuery::ExpenseNonFinanceHighValueCommitDenied
1427        );
1428        assert_eq!(report.execution_identity.backend, "fixed-analysis");
1429        assert_eq!(
1430            report.execution_identity.producer.name,
1431            env!("CARGO_PKG_NAME")
1432        );
1433    }
1434
1435    #[cfg(feature = "analysis")]
1436    #[tokio::test]
1437    async fn cedar_analysis_suggestor_routes_parse_errors_to_diagnostics() {
1438        let s = CedarAnalysisSuggestor::new(FixedAnalysisBackend {
1439            status: crate::analysis::CedarAnalysisExecutionStatus::NoViolation,
1440        });
1441
1442        let mut ctx = MockContext::empty();
1443        ctx.facts.insert(
1444            ContextKey::Seeds,
1445            vec![context_fact(
1446                ContextKey::Seeds,
1447                "not-analysis-input",
1448                TextPayload::new("{not json"),
1449            )],
1450        );
1451
1452        let effect = s.execute(&ctx).await;
1453
1454        assert_eq!(effect.proposals().len(), 1);
1455        assert_eq!(effect.proposals()[0].key, ContextKey::Diagnostic);
1456        assert!(
1457            effect.proposals()[0]
1458                .id
1459                .contains("cedar-analysis-parse-error")
1460        );
1461    }
1462
1463    // ── New gate tests ────────────────────────────────────────────
1464
1465    #[test]
1466    fn rate_limit_gate_name_and_deps() {
1467        let s = RateLimitGateSuggestor::new(ContextKey::Strategies, 10);
1468        assert_eq!(s.name(), "rate-limit-gate");
1469        assert_eq!(s.dependencies(), &[ContextKey::Strategies]);
1470    }
1471
1472    #[test]
1473    fn rate_limit_gate_rejects_empty() {
1474        let s = RateLimitGateSuggestor::new(ContextKey::Strategies, 5);
1475        let ctx = MockContext::empty();
1476        assert!(!s.accepts(&ctx));
1477    }
1478
1479    #[test]
1480    fn budget_gate_name_and_deps() {
1481        let s = BudgetGateSuggestor::new(ContextKey::Strategies, 1000.0);
1482        assert_eq!(s.name(), "budget-gate");
1483        assert_eq!(s.dependencies(), &[ContextKey::Strategies]);
1484    }
1485
1486    #[test]
1487    fn approval_gate_name_and_deps() {
1488        let s = ApprovalGateSuggestor::new(ContextKey::Strategies, 0.9);
1489        assert_eq!(s.name(), "approval-gate");
1490        assert_eq!(s.dependencies(), &[ContextKey::Strategies]);
1491    }
1492
1493    #[test]
1494    fn data_classification_gate_name() {
1495        let s = DataClassificationGateSuggestor::default_patterns(ContextKey::Strategies);
1496        assert_eq!(s.name(), "data-classification-gate");
1497    }
1498
1499    #[test]
1500    fn compliance_gate_name() {
1501        let rules = vec![ComplianceRule {
1502            id: "gdpr-retention".into(),
1503            framework: "GDPR".into(),
1504            field: "retention_days".into(),
1505            condition: ComplianceCondition::MaxValue(365.0),
1506        }];
1507        let s = ComplianceGateSuggestor::new(ContextKey::Strategies, rules);
1508        assert_eq!(s.name(), "compliance-gate");
1509    }
1510
1511    #[tokio::test]
1512    async fn data_classification_detects_email() {
1513        let s = DataClassificationGateSuggestor::default_patterns(ContextKey::Strategies);
1514
1515        let mut ctx = MockContext::empty();
1516        ctx.facts.insert(
1517            ContextKey::Strategies,
1518            vec![context_fact(
1519                ContextKey::Strategies,
1520                "strat-1",
1521                TextPayload::new("Contact john@example.com for details"),
1522            )],
1523        );
1524
1525        assert!(s.accepts(&ctx));
1526        let effect = s.execute(&ctx).await;
1527        assert_eq!(effect.proposals().len(), 1);
1528        assert!(effect.proposals()[0].id.contains("pii-detected"));
1529    }
1530
1531    #[tokio::test]
1532    async fn data_classification_passes_clean_content() {
1533        let s = DataClassificationGateSuggestor::default_patterns(ContextKey::Strategies);
1534
1535        let mut ctx = MockContext::empty();
1536        ctx.facts.insert(
1537            ContextKey::Strategies,
1538            vec![context_fact(
1539                ContextKey::Strategies,
1540                "strat-1",
1541                TextPayload::new("Allocate budget across 4 departments"),
1542            )],
1543        );
1544
1545        assert!(s.accepts(&ctx));
1546        let effect = s.execute(&ctx).await;
1547        assert!(effect.proposals().is_empty());
1548    }
1549
1550    #[tokio::test]
1551    async fn budget_gate_blocks_over_limit() {
1552        let s = BudgetGateSuggestor::new(ContextKey::Strategies, 100.0);
1553
1554        let mut ctx = MockContext::empty();
1555        ctx.facts.insert(
1556            ContextKey::Strategies,
1557            vec![
1558                context_fact(
1559                    ContextKey::Strategies,
1560                    "s1",
1561                    CostEstimatePayload {
1562                        cost: CostUsd::new(60.0).unwrap(),
1563                    },
1564                ),
1565                context_fact(
1566                    ContextKey::Strategies,
1567                    "s2",
1568                    CostEstimatePayload {
1569                        cost: CostUsd::new(50.0).unwrap(),
1570                    },
1571                ),
1572            ],
1573        );
1574
1575        assert!(s.accepts(&ctx));
1576        let effect = s.execute(&ctx).await;
1577        assert_eq!(effect.proposals().len(), 1);
1578        assert!(effect.proposals()[0].id.contains("budget-exceeded"));
1579    }
1580
1581    #[tokio::test]
1582    async fn budget_gate_allows_within_limit() {
1583        let s = BudgetGateSuggestor::new(ContextKey::Strategies, 200.0);
1584
1585        let mut ctx = MockContext::empty();
1586        ctx.facts.insert(
1587            ContextKey::Strategies,
1588            vec![
1589                context_fact(
1590                    ContextKey::Strategies,
1591                    "s1",
1592                    CostEstimatePayload {
1593                        cost: CostUsd::new(60.0).unwrap(),
1594                    },
1595                ),
1596                context_fact(
1597                    ContextKey::Strategies,
1598                    "s2",
1599                    CostEstimatePayload {
1600                        cost: CostUsd::new(50.0).unwrap(),
1601                    },
1602                ),
1603            ],
1604        );
1605
1606        assert!(s.accepts(&ctx));
1607        let effect = s.execute(&ctx).await;
1608        assert!(effect.proposals().is_empty());
1609    }
1610}