use std::sync::Arc;
use converge_core::FlowGateInput;
use converge_pack::{AgentEffect, Context, ContextKey, Suggestor, fact::ProposedFact};
use ed25519_dalek::VerifyingKey;
use crate::delegation;
use crate::engine::PolicyEngine;
use crate::types::DecideRequest;
const PROVENANCE: &str = "converge-policy";
pub struct PolicyGateSuggestor {
engine: Arc<PolicyEngine>,
input_key: ContextKey,
output_key: ContextKey,
}
impl PolicyGateSuggestor {
#[must_use]
pub fn new(engine: Arc<PolicyEngine>) -> Self {
Self {
engine,
input_key: ContextKey::Seeds,
output_key: ContextKey::Constraints,
}
}
#[must_use]
pub fn with_keys(
engine: Arc<PolicyEngine>,
input_key: ContextKey,
output_key: ContextKey,
) -> Self {
Self {
engine,
input_key,
output_key,
}
}
}
#[async_trait::async_trait]
impl Suggestor for PolicyGateSuggestor {
fn name(&self) -> &str {
"policy-gate"
}
fn dependencies(&self) -> &[ContextKey] {
std::slice::from_ref(&self.input_key)
}
fn accepts(&self, ctx: &dyn Context) -> bool {
ctx.has(self.input_key) && !ctx.has(self.output_key)
}
async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
let facts = ctx.get(self.input_key);
let Some(seed) = facts.first() else {
return AgentEffect::empty();
};
let req: DecideRequest = match serde_json::from_str(&seed.content) {
Ok(r) => r,
Err(e) => {
let diag = ProposedFact::new(
ContextKey::Diagnostic,
"policy-gate-error",
format!("failed to parse DecideRequest: {e}"),
PROVENANCE,
);
return AgentEffect::with_proposal(diag);
}
};
match self.engine.evaluate(&req) {
Ok(decision) => {
let content = serde_json::to_string(&decision).unwrap_or_default();
let proposal =
ProposedFact::new(self.output_key, "policy-decision", content, PROVENANCE);
AgentEffect::with_proposal(proposal)
}
Err(e) => {
let diag = ProposedFact::new(
ContextKey::Diagnostic,
"policy-gate-error",
format!("policy evaluation failed: {e}"),
PROVENANCE,
);
AgentEffect::with_proposal(diag)
}
}
}
}
pub struct DelegationVerifySuggestor {
verifying_key: VerifyingKey,
input_key: ContextKey,
output_key: ContextKey,
}
impl DelegationVerifySuggestor {
#[must_use]
pub fn new(verifying_key: VerifyingKey) -> Self {
Self {
verifying_key,
input_key: ContextKey::Seeds,
output_key: ContextKey::Constraints,
}
}
#[must_use]
pub fn with_keys(
verifying_key: VerifyingKey,
input_key: ContextKey,
output_key: ContextKey,
) -> Self {
Self {
verifying_key,
input_key,
output_key,
}
}
}
#[async_trait::async_trait]
impl Suggestor for DelegationVerifySuggestor {
fn name(&self) -> &str {
"delegation-verify"
}
fn dependencies(&self) -> &[ContextKey] {
std::slice::from_ref(&self.input_key)
}
fn accepts(&self, ctx: &dyn Context) -> bool {
ctx.has(self.input_key) && !ctx.has(self.output_key)
}
async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
let facts = ctx.get(self.input_key);
let Some(seed) = facts.first() else {
return AgentEffect::empty();
};
let req: DecideRequest = match serde_json::from_str(&seed.content) {
Ok(r) => r,
Err(e) => {
let diag = ProposedFact::new(
ContextKey::Diagnostic,
"delegation-verify-error",
format!("failed to parse DecideRequest: {e}"),
PROVENANCE,
);
return AgentEffect::with_proposal(diag);
}
};
let Some(ref token_b64) = req.delegation_b64 else {
let diag = ProposedFact::new(
ContextKey::Diagnostic,
"delegation-verify-error",
"no delegation_b64 in request",
PROVENANCE,
);
return AgentEffect::with_proposal(diag);
};
match delegation::verify(token_b64, &self.verifying_key, &req) {
Ok(valid) => {
let content = if valid {
r#"{"valid":true}"#.to_string()
} else {
r#"{"valid":false,"reason":"constraints not met"}"#.to_string()
};
let proposal =
ProposedFact::new(self.output_key, "delegation-result", content, PROVENANCE);
AgentEffect::with_proposal(proposal)
}
Err(e) => {
let content = format!(r#"{{"valid":false,"reason":"{e}"}}"#);
let proposal =
ProposedFact::new(self.output_key, "delegation-result", content, PROVENANCE);
AgentEffect::with_proposal(proposal)
}
}
}
}
pub struct FlowGateSuggestor {
engine: Arc<PolicyEngine>,
input_key: ContextKey,
output_key: ContextKey,
}
impl FlowGateSuggestor {
#[must_use]
pub fn new(engine: Arc<PolicyEngine>) -> Self {
Self {
engine,
input_key: ContextKey::Seeds,
output_key: ContextKey::Constraints,
}
}
#[must_use]
pub fn with_keys(
engine: Arc<PolicyEngine>,
input_key: ContextKey,
output_key: ContextKey,
) -> Self {
Self {
engine,
input_key,
output_key,
}
}
}
#[async_trait::async_trait]
impl Suggestor for FlowGateSuggestor {
fn name(&self) -> &str {
"flow-gate"
}
fn dependencies(&self) -> &[ContextKey] {
std::slice::from_ref(&self.input_key)
}
fn accepts(&self, ctx: &dyn Context) -> bool {
ctx.has(self.input_key) && !ctx.has(self.output_key)
}
async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
let facts = ctx.get(self.input_key);
let Some(seed) = facts.first() else {
return AgentEffect::empty();
};
let input: FlowGateInput = match serde_json::from_str(&seed.content) {
Ok(i) => i,
Err(e) => {
let diag = ProposedFact::new(
ContextKey::Diagnostic,
"flow-gate-error",
format!("failed to parse FlowGateInput: {e}"),
PROVENANCE,
);
return AgentEffect::with_proposal(diag);
}
};
match self.engine.evaluate_flow(&input) {
Ok(decision) => {
let content = serde_json::to_string(&decision).unwrap_or_default();
let proposal =
ProposedFact::new(self.output_key, "flow-gate-decision", content, PROVENANCE);
AgentEffect::with_proposal(proposal)
}
Err(e) => {
let diag = ProposedFact::new(
ContextKey::Diagnostic,
"flow-gate-error",
format!("flow gate evaluation failed: {e}"),
PROVENANCE,
);
AgentEffect::with_proposal(diag)
}
}
}
}
pub struct RateLimitGateSuggestor {
max_proposals_per_key: usize,
watched_key: ContextKey,
}
impl RateLimitGateSuggestor {
#[must_use]
pub fn new(watched_key: ContextKey, max_proposals_per_key: usize) -> Self {
Self {
max_proposals_per_key,
watched_key,
}
}
}
#[async_trait::async_trait]
impl Suggestor for RateLimitGateSuggestor {
fn name(&self) -> &str {
"rate-limit-gate"
}
fn dependencies(&self) -> &[ContextKey] {
std::slice::from_ref(&self.watched_key)
}
fn accepts(&self, ctx: &dyn Context) -> bool {
ctx.count(self.watched_key) > self.max_proposals_per_key
&& !ctx
.get(ContextKey::Constraints)
.iter()
.any(|f| f.id == "rate-limit-exceeded")
}
async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
let count = ctx.count(self.watched_key);
AgentEffect::with_proposal(ProposedFact::new(
ContextKey::Constraints,
"rate-limit-exceeded",
serde_json::json!({
"gate": "rate-limit",
"key": format!("{:?}", self.watched_key),
"count": count,
"limit": self.max_proposals_per_key,
"action": "block",
})
.to_string(),
PROVENANCE,
))
}
}
pub struct BudgetGateSuggestor {
max_cost: f64,
cost_key: ContextKey,
}
impl BudgetGateSuggestor {
#[must_use]
pub fn new(cost_key: ContextKey, max_cost: f64) -> Self {
Self { max_cost, cost_key }
}
}
#[async_trait::async_trait]
impl Suggestor for BudgetGateSuggestor {
fn name(&self) -> &str {
"budget-gate"
}
fn dependencies(&self) -> &[ContextKey] {
std::slice::from_ref(&self.cost_key)
}
fn accepts(&self, ctx: &dyn Context) -> bool {
ctx.has(self.cost_key)
&& !ctx
.get(ContextKey::Constraints)
.iter()
.any(|f| f.id == "budget-exceeded")
}
async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
let facts = ctx.get(self.cost_key);
let total_cost: f64 = facts
.iter()
.filter_map(|f| {
serde_json::from_str::<serde_json::Value>(&f.content)
.ok()
.and_then(|v| v.get("cost").and_then(|c| c.as_f64()))
})
.sum();
if total_cost > self.max_cost {
AgentEffect::with_proposal(ProposedFact::new(
ContextKey::Constraints,
"budget-exceeded",
serde_json::json!({
"gate": "budget",
"total_cost": total_cost,
"limit": self.max_cost,
"action": "block",
})
.to_string(),
PROVENANCE,
))
} else {
AgentEffect::empty()
}
}
}
pub struct ApprovalGateSuggestor {
watched_key: ContextKey,
approval_key: ContextKey,
stakes_threshold: f64,
}
impl ApprovalGateSuggestor {
#[must_use]
pub fn new(watched_key: ContextKey, stakes_threshold: f64) -> Self {
Self {
watched_key,
approval_key: ContextKey::Signals,
stakes_threshold,
}
}
#[must_use]
pub fn with_approval_key(mut self, key: ContextKey) -> Self {
self.approval_key = key;
self
}
}
#[async_trait::async_trait]
impl Suggestor for ApprovalGateSuggestor {
fn name(&self) -> &str {
"approval-gate"
}
fn dependencies(&self) -> &[ContextKey] {
std::slice::from_ref(&self.watched_key)
}
fn accepts(&self, ctx: &dyn Context) -> bool {
ctx.has(self.watched_key)
&& !ctx.has(self.approval_key)
&& !ctx
.get(ContextKey::Constraints)
.iter()
.any(|f| f.id == "approval-pending")
}
async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
let facts = ctx.get(self.watched_key);
let needs_approval = facts.iter().any(|f| {
serde_json::from_str::<serde_json::Value>(&f.content)
.ok()
.and_then(|v| v.get("confidence").and_then(|c| c.as_f64()))
.is_none_or(|c| c >= self.stakes_threshold)
});
if needs_approval {
AgentEffect::with_proposal(ProposedFact::new(
ContextKey::Constraints,
"approval-pending",
serde_json::json!({
"gate": "approval",
"status": "pending_human_review",
"threshold": self.stakes_threshold,
"action": "pause",
})
.to_string(),
PROVENANCE,
))
} else {
AgentEffect::empty()
}
}
}
pub struct DataClassificationGateSuggestor {
watched_key: ContextKey,
patterns: Vec<(&'static str, regex::Regex)>,
}
impl DataClassificationGateSuggestor {
#[must_use]
pub fn default_patterns(watched_key: ContextKey) -> Self {
Self {
watched_key,
patterns: vec![
(
"email",
regex::Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
.expect("valid regex"),
),
(
"ssn",
regex::Regex::new(r"\b\d{3}-\d{2}-\d{4}\b").expect("valid regex"),
),
(
"credit_card",
regex::Regex::new(r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b")
.expect("valid regex"),
),
(
"phone",
regex::Regex::new(r"\b\+?\d{1,3}[-.\s]?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b")
.expect("valid regex"),
),
],
}
}
}
#[async_trait::async_trait]
impl Suggestor for DataClassificationGateSuggestor {
fn name(&self) -> &str {
"data-classification-gate"
}
fn dependencies(&self) -> &[ContextKey] {
std::slice::from_ref(&self.watched_key)
}
fn accepts(&self, ctx: &dyn Context) -> bool {
ctx.has(self.watched_key)
&& !ctx
.get(ContextKey::Constraints)
.iter()
.any(|f| f.id.starts_with("pii-detected-"))
}
async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
let facts = ctx.get(self.watched_key);
let mut proposals = Vec::new();
for fact in facts {
let mut detected = Vec::new();
for (label, pattern) in &self.patterns {
if pattern.is_match(&fact.content) {
detected.push(*label);
}
}
if !detected.is_empty() {
proposals.push(ProposedFact::new(
ContextKey::Constraints,
format!("pii-detected-{}", fact.id),
serde_json::json!({
"gate": "data-classification",
"fact_id": fact.id,
"detected_types": detected,
"action": "block",
})
.to_string(),
PROVENANCE,
));
}
}
AgentEffect::with_proposals(proposals)
}
}
pub struct ComplianceGateSuggestor {
watched_key: ContextKey,
rules: Vec<ComplianceRule>,
}
pub struct ComplianceRule {
pub id: String,
pub framework: String,
pub field: String,
pub condition: ComplianceCondition,
}
pub enum ComplianceCondition {
FieldMustNotExist,
MaxValue(f64),
MustNotContain(Vec<String>),
}
impl ComplianceGateSuggestor {
#[must_use]
pub fn new(watched_key: ContextKey, rules: Vec<ComplianceRule>) -> Self {
Self { watched_key, rules }
}
}
#[async_trait::async_trait]
impl Suggestor for ComplianceGateSuggestor {
fn name(&self) -> &str {
"compliance-gate"
}
fn dependencies(&self) -> &[ContextKey] {
std::slice::from_ref(&self.watched_key)
}
fn accepts(&self, ctx: &dyn Context) -> bool {
ctx.has(self.watched_key)
&& !ctx
.get(ContextKey::Constraints)
.iter()
.any(|f| f.id.starts_with("compliance-"))
}
async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
let facts = ctx.get(self.watched_key);
let mut proposals = Vec::new();
for fact in facts {
let Ok(value) = serde_json::from_str::<serde_json::Value>(&fact.content) else {
continue;
};
for rule in &self.rules {
let violated = match &rule.condition {
ComplianceCondition::FieldMustNotExist => value.get(&rule.field).is_some(),
ComplianceCondition::MaxValue(max) => value
.get(&rule.field)
.and_then(|v| v.as_f64())
.is_some_and(|v| v > *max),
ComplianceCondition::MustNotContain(forbidden) => value
.get(&rule.field)
.and_then(|v| v.as_str())
.is_some_and(|s| forbidden.iter().any(|f| s.contains(f.as_str()))),
};
if violated {
proposals.push(ProposedFact::new(
ContextKey::Constraints,
format!("compliance-{}-{}", rule.id, fact.id),
serde_json::json!({
"gate": "compliance",
"rule_id": rule.id,
"framework": rule.framework,
"fact_id": fact.id,
"field": rule.field,
"action": "block",
})
.to_string(),
PROVENANCE,
));
}
}
}
AgentEffect::with_proposals(proposals)
}
}
#[cfg(test)]
mod tests {
use super::*;
use converge_pack::fact::Fact;
use std::collections::HashMap;
struct MockContext {
facts: HashMap<ContextKey, Vec<Fact>>,
}
impl MockContext {
fn empty() -> Self {
Self {
facts: HashMap::new(),
}
}
}
impl Context for MockContext {
fn has(&self, key: ContextKey) -> bool {
self.facts.get(&key).is_some_and(|v| !v.is_empty())
}
fn get(&self, key: ContextKey) -> &[Fact] {
self.facts.get(&key).map_or(&[], Vec::as_slice)
}
}
#[test]
fn policy_gate_name() {
let engine = Arc::new(
PolicyEngine::from_policy_str("permit(principal, action, resource);").unwrap(),
);
let s = PolicyGateSuggestor::new(engine);
assert_eq!(s.name(), "policy-gate");
}
#[test]
fn policy_gate_dependencies() {
let engine = Arc::new(
PolicyEngine::from_policy_str("permit(principal, action, resource);").unwrap(),
);
let s = PolicyGateSuggestor::new(engine);
assert_eq!(s.dependencies(), &[ContextKey::Seeds]);
}
#[test]
fn policy_gate_rejects_empty_context() {
let engine = Arc::new(
PolicyEngine::from_policy_str("permit(principal, action, resource);").unwrap(),
);
let s = PolicyGateSuggestor::new(engine);
let ctx = MockContext::empty();
assert!(!s.accepts(&ctx));
}
#[test]
fn delegation_verify_name() {
let key = ed25519_dalek::SigningKey::from_bytes(&[42u8; 32]).verifying_key();
let s = DelegationVerifySuggestor::new(key);
assert_eq!(s.name(), "delegation-verify");
}
#[test]
fn flow_gate_name() {
let engine = Arc::new(
PolicyEngine::from_policy_str("permit(principal, action, resource);").unwrap(),
);
let s = FlowGateSuggestor::new(engine);
assert_eq!(s.name(), "flow-gate");
}
#[test]
fn flow_gate_rejects_empty_context() {
let engine = Arc::new(
PolicyEngine::from_policy_str("permit(principal, action, resource);").unwrap(),
);
let s = FlowGateSuggestor::new(engine);
let ctx = MockContext::empty();
assert!(!s.accepts(&ctx));
}
#[test]
fn rate_limit_gate_name_and_deps() {
let s = RateLimitGateSuggestor::new(ContextKey::Strategies, 10);
assert_eq!(s.name(), "rate-limit-gate");
assert_eq!(s.dependencies(), &[ContextKey::Strategies]);
}
#[test]
fn rate_limit_gate_rejects_empty() {
let s = RateLimitGateSuggestor::new(ContextKey::Strategies, 5);
let ctx = MockContext::empty();
assert!(!s.accepts(&ctx));
}
#[test]
fn budget_gate_name_and_deps() {
let s = BudgetGateSuggestor::new(ContextKey::Strategies, 1000.0);
assert_eq!(s.name(), "budget-gate");
assert_eq!(s.dependencies(), &[ContextKey::Strategies]);
}
#[test]
fn approval_gate_name_and_deps() {
let s = ApprovalGateSuggestor::new(ContextKey::Strategies, 0.9);
assert_eq!(s.name(), "approval-gate");
assert_eq!(s.dependencies(), &[ContextKey::Strategies]);
}
#[test]
fn data_classification_gate_name() {
let s = DataClassificationGateSuggestor::default_patterns(ContextKey::Strategies);
assert_eq!(s.name(), "data-classification-gate");
}
#[test]
fn compliance_gate_name() {
let rules = vec![ComplianceRule {
id: "gdpr-retention".into(),
framework: "GDPR".into(),
field: "retention_days".into(),
condition: ComplianceCondition::MaxValue(365.0),
}];
let s = ComplianceGateSuggestor::new(ContextKey::Strategies, rules);
assert_eq!(s.name(), "compliance-gate");
}
#[tokio::test]
async fn data_classification_detects_email() {
use converge_pack::fact::kernel_authority::new_fact;
let s = DataClassificationGateSuggestor::default_patterns(ContextKey::Strategies);
let mut ctx = MockContext::empty();
ctx.facts.insert(
ContextKey::Strategies,
vec![new_fact(
ContextKey::Strategies,
"strat-1",
"Contact john@example.com for details",
)],
);
assert!(s.accepts(&ctx));
let effect = s.execute(&ctx).await;
assert_eq!(effect.proposals.len(), 1);
assert!(effect.proposals[0].id.contains("pii-detected"));
}
#[tokio::test]
async fn data_classification_passes_clean_content() {
use converge_pack::fact::kernel_authority::new_fact;
let s = DataClassificationGateSuggestor::default_patterns(ContextKey::Strategies);
let mut ctx = MockContext::empty();
ctx.facts.insert(
ContextKey::Strategies,
vec![new_fact(
ContextKey::Strategies,
"strat-1",
"Allocate budget across 4 departments",
)],
);
assert!(s.accepts(&ctx));
let effect = s.execute(&ctx).await;
assert!(effect.proposals.is_empty());
}
#[tokio::test]
async fn budget_gate_blocks_over_limit() {
use converge_pack::fact::kernel_authority::new_fact;
let s = BudgetGateSuggestor::new(ContextKey::Strategies, 100.0);
let mut ctx = MockContext::empty();
ctx.facts.insert(
ContextKey::Strategies,
vec![
new_fact(ContextKey::Strategies, "s1", r#"{"cost": 60.0}"#),
new_fact(ContextKey::Strategies, "s2", r#"{"cost": 50.0}"#),
],
);
assert!(s.accepts(&ctx));
let effect = s.execute(&ctx).await;
assert_eq!(effect.proposals.len(), 1);
assert!(effect.proposals[0].id.contains("budget-exceeded"));
}
#[tokio::test]
async fn budget_gate_allows_within_limit() {
use converge_pack::fact::kernel_authority::new_fact;
let s = BudgetGateSuggestor::new(ContextKey::Strategies, 200.0);
let mut ctx = MockContext::empty();
ctx.facts.insert(
ContextKey::Strategies,
vec![
new_fact(ContextKey::Strategies, "s1", r#"{"cost": 60.0}"#),
new_fact(ContextKey::Strategies, "s2", r#"{"cost": 50.0}"#),
],
);
assert!(s.accepts(&ctx));
let effect = s.execute(&ctx).await;
assert!(effect.proposals.is_empty());
}
}