Skip to main content

agentstategraph_policy/
types.rs

1//! Public data types for policies, change proposals, and decisions.
2//!
3//! Follows POLICY_V1.md §2.1 + §22.2/3. `triggers`, `required_fields`,
4//! `severity`, `FallbackAction`, and `ChangeProposal` are the v1.1
5//! cost-of-change additions.
6
7use std::collections::HashMap;
8use std::time::Duration;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use crate::selector::Selector;
14
15/// The unit of authorization + procedure.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct Policy {
18    /// Normalized path under the store prefix, e.g. `"infra/k8s/pod-failing"`.
19    pub path: String,
20    /// Monotonically increasing version, starting at 1 for a fresh
21    /// `propose`, incremented by `supersede`.
22    pub version: u64,
23
24    /// Human-readable situation description.
25    pub situation: String,
26    /// Machine-evaluable situation matcher.
27    pub situation_selector: Selector,
28
29    #[serde(default)]
30    pub allow: Vec<AuthorizedAction>,
31    #[serde(default)]
32    pub deny: Vec<AuthorizedAction>,
33    #[serde(default)]
34    pub require_approval: Vec<ApprovalRule>,
35
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub procedure: Option<Vec<ProcedureStep>>,
38
39    /// Opaque tokens used to match this policy against a `ChangeProposal`.
40    /// See §22.2 — if any proposal token appears in `triggers`, this
41    /// policy is consulted.
42    #[serde(default)]
43    pub triggers: Vec<String>,
44    /// Fields that a `ChangeProposal` must carry to be promoted under
45    /// this policy. Missing any required field short-circuits to
46    /// `RequireApproval`.
47    #[serde(default)]
48    pub required_fields: Vec<String>,
49    /// Advisory severity for sorting/rendering. Does not change decision
50    /// semantics.
51    #[serde(default)]
52    pub severity: Severity,
53
54    pub proposed_by: String,
55    pub proposed_at: DateTime<Utc>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub ratified_by: Option<String>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub ratified_at: Option<DateTime<Utc>>,
60    /// Ratifier's reasoning (free-form text captured at ratification).
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub ratification_reasoning: Option<String>,
63
64    pub active_from: DateTime<Utc>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub expires_at: Option<DateTime<Utc>>,
67    /// Prior `path@version` string if this policy supersedes another.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub supersedes: Option<String>,
70
71    /// Detached signature over the canonical-JSON bytes of this policy
72    /// (with the `signature` field itself excluded from canonicalization).
73    /// Optional on every policy — unsigned policies remain valid unless
74    /// the server sets `require_signed_policies` (§2c) and a verifier
75    /// has been registered on the `PolicyStore`.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub signature: Option<PolicySignature>,
78
79    /// Tenant identifier for namespace isolation (0.7.5 §3). `None`
80    /// means the policy is a "global" policy that applies to every
81    /// tenant; `Some(id)` means the policy only applies to callers
82    /// that pass a matching `tenant_filter` into `evaluate` /
83    /// `evaluate_change`. See POLICY_V1.md §17 and ROADMAP D3 for
84    /// the cheap-namespace-discriminator rationale.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub tenant_id: Option<String>,
87
88    /// External evaluator escape-hatch (0.7.5 §4). When `Some(_)` and
89    /// the `PolicyStore` has a matching runner registered via
90    /// `with_external_evaluators`, the dispatcher routes the policy to
91    /// the external rule engine (Rego / Cedar / WASM) instead of the
92    /// local evaluator. Policies referencing a runner kind that is not
93    /// registered are treated as not-matching. See POLICY_V1.md §18
94    /// and ROADMAP D4.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub external_evaluator: Option<ExternalEvaluatorRef>,
97}
98
99/// Reference to an external policy evaluator (0.7.5 §4). Tagged serde
100/// union keyed by `kind`; each variant carries an [`EvaluatorSource`]
101/// that the dispatcher resolves at evaluation time.
102///
103/// Concrete runners live in optional sibling crates
104/// (`agentstategraph-policy-wasm` / `-rego` / `-cedar` — §4b). The
105/// main policy crate only carries the ref + trait; unregistered kinds
106/// cause the policy to be skipped during evaluation.
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
108#[serde(tag = "kind", rename_all = "snake_case")]
109pub enum ExternalEvaluatorRef {
110    Rego { source: EvaluatorSource },
111    Cedar { source: EvaluatorSource },
112    Wasm { source: EvaluatorSource },
113}
114
115/// Source location for the body of an external evaluator (0.7.5 §4).
116/// Tagged serde union keyed by `kind`.
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
118#[serde(tag = "kind", rename_all = "snake_case")]
119pub enum EvaluatorSource {
120    /// Rego / Cedar / WASM source embedded directly in the policy.
121    Inline { body: String },
122    /// Absolute or server-relative filesystem path.
123    FilePath { path: std::path::PathBuf },
124    /// A path within the state tree on the same repo — the dispatcher
125    /// loads the value stored there and treats it as the evaluator
126    /// source.
127    CommitRef { path: String },
128}
129
130/// Detached signature carried on a [`Policy`]. Tagged serde union keyed
131/// by `algorithm`; Ed25519 is the only variant shipped in 0.7.5.
132///
133/// The concrete verifier lives in `agentstategraph-policy-sign`
134/// (`Ed25519Verifier`); the main policy crate only stores + passes
135/// through the payload so unsigned policies don't pay the crypto
136/// dep cost.
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
138#[serde(tag = "algorithm", rename_all = "snake_case")]
139pub enum PolicySignature {
140    Ed25519 {
141        /// Identifier of the key in the verifier's registry.
142        signer_key_id: String,
143        /// 128-character lowercase hex string (64 bytes decoded).
144        signature_hex: String,
145    },
146}
147
148impl Policy {
149    /// `true` when this policy has been ratified (live).
150    pub fn is_ratified(&self) -> bool {
151        self.ratified_by.is_some()
152    }
153
154    /// `true` when this policy is currently active against the given
155    /// clock: ratified AND `active_from <= now` AND the policy has not
156    /// expired (`expires_at.is_none() || expires_at > now`).
157    ///
158    /// `expires_at` is an exclusive upper bound — a policy whose
159    /// `expires_at == now` is already expired.
160    pub fn is_currently_active(&self, now: DateTime<Utc>) -> bool {
161        if !self.is_ratified() || self.active_from > now {
162            return false;
163        }
164        !matches!(self.expires_at, Some(exp) if exp <= now)
165    }
166
167    /// Canonical `path@version` identifier.
168    pub fn handle(&self) -> String {
169        format!("{}@{}", self.path, self.version)
170    }
171}
172
173/// One allow/deny rule.
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
175pub struct AuthorizedAction {
176    pub action: String,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub condition: Option<String>,
179    #[serde(default)]
180    pub preconditions: Vec<String>,
181}
182
183/// One require-approval rule. `fallback` tells the caller what to do
184/// while approval is pending (POLICY_V1.md §22.3).
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186pub struct ApprovalRule {
187    pub action: String,
188    pub approvers: Vec<String>,
189    #[serde(
190        default,
191        skip_serializing_if = "Option::is_none",
192        with = "duration_opt"
193    )]
194    pub timeout: Option<Duration>,
195    pub fallback: FallbackAction,
196}
197
198/// One step in a policy's procedure.
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
200pub struct ProcedureStep {
201    pub action: String,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub if_previous_failed: Option<String>,
204}
205
206/// Advisory severity.
207#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
208#[serde(rename_all = "snake_case")]
209pub enum Severity {
210    #[default]
211    Low,
212    Medium,
213    High,
214    Critical,
215}
216
217/// What the agent should do while a change is awaiting approval.
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
219#[serde(tag = "kind", rename_all = "snake_case")]
220pub enum FallbackAction {
221    /// Do nothing; wait for approval.
222    Block,
223    /// Run the named alternative action.
224    PickAlternative { action: String },
225    /// Pick the lowest-risk option from `ChangeProposal::alternatives`.
226    LowestRiskAlternative,
227    /// Leave current state unchanged; record the preferred option as
228    /// a pending upgrade.
229    KeepCurrentState,
230    /// Delegate to another policy by path.
231    DelegateTo { policy_path: String },
232}
233
234/// A proposed change evaluated against change-cost policies.
235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
236pub struct ChangeProposal {
237    pub action: String,
238    pub agent_id: String,
239    pub intent: String,
240    pub preferred_option: String,
241    #[serde(default)]
242    pub alternatives: Vec<String>,
243    #[serde(default)]
244    pub tokens: Vec<String>,
245    #[serde(default)]
246    pub attached_fields: HashMap<String, String>,
247}
248
249impl ChangeProposal {
250    pub fn new(
251        action: impl Into<String>,
252        agent_id: impl Into<String>,
253        intent: impl Into<String>,
254        preferred_option: impl Into<String>,
255    ) -> Self {
256        Self {
257            action: action.into(),
258            agent_id: agent_id.into(),
259            intent: intent.into(),
260            preferred_option: preferred_option.into(),
261            alternatives: Vec::new(),
262            tokens: Vec::new(),
263            attached_fields: HashMap::new(),
264        }
265    }
266
267    pub fn with_tokens<I, S>(mut self, tokens: I) -> Self
268    where
269        I: IntoIterator<Item = S>,
270        S: Into<String>,
271    {
272        self.tokens = tokens.into_iter().map(Into::into).collect();
273        self
274    }
275
276    pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
277        self.attached_fields.insert(key.into(), value.into());
278        self
279    }
280}
281
282/// Result of a policy evaluation.
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
284#[serde(tag = "kind", rename_all = "snake_case")]
285pub enum Decision {
286    Allow {
287        matched_policy: String,
288        #[serde(default)]
289        preconditions: Vec<String>,
290    },
291    Deny {
292        matched_policy: String,
293        reason: String,
294    },
295    RequireApproval {
296        matched_policy: String,
297        approvers: Vec<String>,
298        #[serde(default, with = "duration_opt")]
299        timeout: Option<Duration>,
300        fallback: FallbackAction,
301        #[serde(default)]
302        approval_task_path: Option<String>,
303    },
304    NoPolicyMatch,
305}
306
307impl Decision {
308    /// The decision's kind, as it serializes: `allow`, `deny`,
309    /// `require_approval` or `no_policy_match`. Telemetry records it as
310    /// `asg.policy.decision`.
311    pub fn kind(&self) -> &'static str {
312        match self {
313            Decision::Allow { .. } => "allow",
314            Decision::Deny { .. } => "deny",
315            Decision::RequireApproval { .. } => "require_approval",
316            Decision::NoPolicyMatch => "no_policy_match",
317        }
318    }
319}
320
321/// Serde helper for `Option<Duration>` encoded as milliseconds.
322mod duration_opt {
323    use std::time::Duration;
324
325    use serde::{Deserialize, Deserializer, Serialize, Serializer};
326
327    pub fn serialize<S>(d: &Option<Duration>, s: S) -> Result<S::Ok, S::Error>
328    where
329        S: Serializer,
330    {
331        match d {
332            Some(d) => (d.as_millis() as u64).serialize(s),
333            None => Option::<u64>::None.serialize(s),
334        }
335    }
336
337    pub fn deserialize<'de, D>(d: D) -> Result<Option<Duration>, D::Error>
338    where
339        D: Deserializer<'de>,
340    {
341        let opt = Option::<u64>::deserialize(d)?;
342        Ok(opt.map(Duration::from_millis))
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn severity_roundtrips() {
352        for s in [
353            Severity::Low,
354            Severity::Medium,
355            Severity::High,
356            Severity::Critical,
357        ] {
358            let j = serde_json::to_value(s).unwrap();
359            let back: Severity = serde_json::from_value(j).unwrap();
360            assert_eq!(s, back);
361        }
362    }
363
364    #[test]
365    fn fallback_action_all_variants_serialize() {
366        let variants = vec![
367            FallbackAction::Block,
368            FallbackAction::PickAlternative {
369                action: "apply_safe".into(),
370            },
371            FallbackAction::LowestRiskAlternative,
372            FallbackAction::KeepCurrentState,
373            FallbackAction::DelegateTo {
374                policy_path: "infra/fallback".into(),
375            },
376        ];
377        for v in variants {
378            let json = serde_json::to_value(&v).unwrap();
379            assert!(json.get("kind").is_some(), "tagged enum should emit kind");
380            let back: FallbackAction = serde_json::from_value(json).unwrap();
381            assert_eq!(v, back);
382        }
383    }
384
385    #[test]
386    fn decision_roundtrips() {
387        let d = Decision::RequireApproval {
388            matched_policy: "infra/k8s/pod-failing@1".into(),
389            approvers: vec!["human".into()],
390            timeout: Some(Duration::from_secs(3600)),
391            fallback: FallbackAction::LowestRiskAlternative,
392            approval_task_path: None,
393        };
394        let j = serde_json::to_value(&d).unwrap();
395        let back: Decision = serde_json::from_value(j).unwrap();
396        assert_eq!(d, back);
397    }
398
399    #[test]
400    fn change_proposal_builder() {
401        let p = ChangeProposal::new("promote", "agent-1", "merge option C", "spec-7")
402            .with_tokens(["reindex", "downtime"])
403            .with_field("estimated_downtime", "5m");
404        assert_eq!(
405            p.tokens,
406            vec!["reindex".to_string(), "downtime".to_string()]
407        );
408        assert_eq!(p.attached_fields.get("estimated_downtime").unwrap(), "5m");
409    }
410
411    // --- Policy lifecycle helpers ---
412
413    fn base_policy(path: &str) -> Policy {
414        use crate::selector::Selector;
415        Policy {
416            path: path.into(),
417            version: 1,
418            situation: "test".into(),
419            situation_selector: Selector::Always,
420            allow: vec![],
421            deny: vec![],
422            require_approval: vec![],
423            procedure: None,
424            triggers: vec![],
425            required_fields: vec![],
426            severity: Severity::default(),
427            proposed_by: "claude".into(),
428            proposed_at: Utc::now(),
429            ratified_by: None,
430            ratified_at: None,
431            ratification_reasoning: None,
432            active_from: Utc::now(),
433            expires_at: None,
434            supersedes: None,
435            signature: None,
436            tenant_id: None,
437            external_evaluator: None,
438        }
439    }
440
441    #[test]
442    fn is_ratified_true_when_ratified_by_set() {
443        let mut p = base_policy("a");
444        p.ratified_by = Some("alice".into());
445        assert!(p.is_ratified());
446    }
447
448    #[test]
449    fn is_ratified_false_when_none() {
450        assert!(!base_policy("a").is_ratified());
451    }
452
453    #[test]
454    fn is_currently_active_requires_ratification() {
455        let p = base_policy("a");
456        assert!(!p.is_currently_active(Utc::now()));
457    }
458
459    #[test]
460    fn is_currently_active_true_when_ratified_and_in_window() {
461        use chrono::Duration;
462        let mut p = base_policy("a");
463        p.ratified_by = Some("alice".into());
464        p.active_from = Utc::now() - Duration::hours(1);
465        assert!(p.is_currently_active(Utc::now()));
466    }
467
468    #[test]
469    fn is_currently_active_false_when_future_active_from() {
470        use chrono::Duration;
471        let mut p = base_policy("a");
472        p.ratified_by = Some("alice".into());
473        p.active_from = Utc::now() + Duration::hours(1);
474        assert!(!p.is_currently_active(Utc::now()));
475    }
476
477    #[test]
478    fn is_currently_active_false_when_expired() {
479        use chrono::Duration;
480        let mut p = base_policy("a");
481        p.ratified_by = Some("alice".into());
482        p.active_from = Utc::now() - Duration::hours(2);
483        p.expires_at = Some(Utc::now() - Duration::hours(1)); // expired one hour ago
484        assert!(!p.is_currently_active(Utc::now()));
485    }
486
487    #[test]
488    fn is_currently_active_false_when_expires_at_equals_now() {
489        // expires_at is exclusive upper bound
490        let now = Utc::now();
491        let mut p = base_policy("a");
492        p.ratified_by = Some("alice".into());
493        p.active_from = now;
494        p.expires_at = Some(now);
495        assert!(!p.is_currently_active(now));
496    }
497
498    #[test]
499    fn is_currently_active_true_just_before_expiry() {
500        use chrono::Duration;
501        let now = Utc::now();
502        let mut p = base_policy("a");
503        p.ratified_by = Some("alice".into());
504        p.active_from = now - Duration::hours(1);
505        p.expires_at = Some(now + Duration::seconds(1));
506        assert!(p.is_currently_active(now));
507    }
508
509    #[test]
510    fn policy_handle_format() {
511        let mut p = base_policy("infra/k8s/pod-failing");
512        p.version = 3;
513        assert_eq!(p.handle(), "infra/k8s/pod-failing@3");
514    }
515
516    // --- ExternalEvaluatorRef + EvaluatorSource ---
517
518    #[test]
519    fn external_evaluator_ref_all_variants_roundtrip() {
520        use std::path::PathBuf;
521        let sources = vec![
522            EvaluatorSource::Inline {
523                body: "package p\ndefault allow = false".into(),
524            },
525            EvaluatorSource::FilePath {
526                path: PathBuf::from("/policies/deny.rego"),
527            },
528            EvaluatorSource::CommitRef {
529                path: "/_policy/rego/main".into(),
530            },
531        ];
532        for src in sources {
533            let refs = vec![
534                ExternalEvaluatorRef::Rego {
535                    source: src.clone(),
536                },
537                ExternalEvaluatorRef::Cedar {
538                    source: src.clone(),
539                },
540                ExternalEvaluatorRef::Wasm {
541                    source: src.clone(),
542                },
543            ];
544            for r in refs {
545                let j = serde_json::to_value(&r).unwrap();
546                assert!(j.get("kind").is_some());
547                let back: ExternalEvaluatorRef = serde_json::from_value(j).unwrap();
548                assert_eq!(r, back);
549            }
550        }
551    }
552
553    #[test]
554    fn policy_signature_roundtrip() {
555        let sig = PolicySignature::Ed25519 {
556            signer_key_id: "key-001".into(),
557            signature_hex: "a".repeat(128),
558        };
559        let j = serde_json::to_value(&sig).unwrap();
560        assert_eq!(j["algorithm"], "ed25519");
561        let back: PolicySignature = serde_json::from_value(j).unwrap();
562        assert_eq!(sig, back);
563    }
564
565    #[test]
566    fn approval_rule_timeout_roundtrips_as_millis() {
567        let rule = ApprovalRule {
568            action: "deploy".into(),
569            approvers: vec!["ops".into()],
570            timeout: Some(Duration::from_secs(7200)),
571            fallback: FallbackAction::Block,
572        };
573        let j = serde_json::to_value(&rule).unwrap();
574        assert_eq!(j["timeout"], 7_200_000u64);
575        let back: ApprovalRule = serde_json::from_value(j).unwrap();
576        assert_eq!(back.timeout, Some(Duration::from_secs(7200)));
577    }
578
579    #[test]
580    fn approval_rule_no_timeout_omitted_from_json() {
581        let rule = ApprovalRule {
582            action: "deploy".into(),
583            approvers: vec![],
584            timeout: None,
585            fallback: FallbackAction::Block,
586        };
587        let j = serde_json::to_string(&rule).unwrap();
588        assert!(
589            !j.contains("timeout"),
590            "None timeout should be omitted: {j}"
591        );
592    }
593
594    #[test]
595    fn authorized_action_optional_condition_omitted_when_none() {
596        let a = AuthorizedAction {
597            action: "restart".into(),
598            condition: None,
599            preconditions: vec![],
600        };
601        let j = serde_json::to_string(&a).unwrap();
602        assert!(
603            !j.contains("\"condition\""),
604            "None condition should be omitted: {j}"
605        );
606    }
607
608    #[test]
609    fn severity_default_is_low() {
610        assert_eq!(Severity::default(), Severity::Low);
611    }
612
613    #[test]
614    fn change_proposal_defaults() {
615        let p = ChangeProposal::new("act", "agent", "intent", "preferred");
616        assert!(p.tokens.is_empty());
617        assert!(p.attached_fields.is_empty());
618        assert!(p.alternatives.is_empty());
619    }
620}