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#[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
296pub 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#[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
542pub 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
611pub 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
673pub 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
729pub 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
797pub struct ApprovalGateSuggestor {
802 watched_key: ContextKey,
803 approval_key: ContextKey,
804 stakes_threshold: Confidence,
805}
806
807impl ApprovalGateSuggestor {
808 #[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
873static 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
903pub struct DataClassificationGateSuggestor {
906 watched_key: ContextKey,
907 patterns: Vec<(&'static str, regex::Regex)>,
908}
909
910impl DataClassificationGateSuggestor {
911 #[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
975pub struct ComplianceGateSuggestor {
980 watched_key: ContextKey,
981 rules: Vec<ComplianceRule>,
982}
983
984pub struct ComplianceRule {
986 pub id: String,
988 pub framework: String,
990 pub field: String,
992 pub condition: ComplianceCondition,
994}
995
996pub enum ComplianceCondition {
1017 FieldMustNotExist,
1019 MaxValue(f64),
1021 MustNotContain(Vec<String>),
1023 RegexMatch(String),
1025 NumericRange { lo: f64, hi: f64 },
1028 CrossField {
1034 antecedent_field: String,
1035 antecedent_value: String,
1036 consequent_field: String,
1037 consequent_value: String,
1038 },
1039 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 #[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}