Skip to main content

agentstategraph_policy/
store.rs

1//! `PolicyStore` — handle bound to a `Repository` + path prefix.
2//!
3//! All policy operations go through this type. Writes commit with
4//! `IntentCategory::PolicyPropose` / `PolicyRatify` / `PolicySupersede` /
5//! `PolicySign` so policy activity is natively filterable in log and blame
6//! queries.
7
8use std::sync::Arc;
9
10use agentstategraph::{CommitOptions, RepoError, Repository};
11use agentstategraph_core::IntentCategory;
12use chrono::Utc;
13
14use crate::error::PolicyError;
15use crate::evaluator;
16use crate::external::ExternalEvaluatorRegistry;
17use crate::paths;
18use crate::selector::Situation;
19use crate::types::{ChangeProposal, Decision, ExternalEvaluatorRef, Policy, PolicySignature};
20use crate::verifier::SignatureVerifier;
21
22/// How many times a read-modify-write rebuilds on a moved head before
23/// giving up with a write conflict (matches the task store).
24const MAX_CAS_RETRIES: u32 = 32;
25
26/// The span of a policy evaluation, named `$name`, with the fields
27/// [`evaluation`] records and any given.
28macro_rules! evaluation_span {
29    ($name:literal $(, $($fields:tt)*)?) => {
30        tracing::info_span!(
31            $name,
32            asg.policy.decision = tracing::field::Empty,
33            error.type = tracing::field::Empty,
34            otel.status_code = tracing::field::Empty,
35            $($($fields)*)?
36        )
37    };
38}
39
40/// Describe the metrics to the recorder, before the first is recorded.
41fn describe_metrics() {
42    static DESCRIBED: std::sync::Once = std::sync::Once::new();
43    DESCRIBED.call_once(|| {
44        metrics::describe_counter!(
45            "asg.policy.decisions",
46            "Policy evaluations, by the decision they reached."
47        );
48        metrics::describe_counter!(
49            "asg.policy.evaluator.errors",
50            "External policy evaluations that failed, so that their policies were skipped."
51        );
52    });
53}
54
55/// Run an evaluation in its span, recording its decision or how it failed,
56/// and count the decision in `asg.policy.decisions`.
57fn evaluation(
58    span: tracing::Span,
59    evaluate: impl FnOnce() -> Result<Decision, PolicyError>,
60) -> Result<Decision, PolicyError> {
61    let _entered = span.enter();
62    let result = evaluate();
63    describe_metrics();
64    match &result {
65        Ok(decision) => {
66            span.record("asg.policy.decision", decision.kind());
67            metrics::counter!("asg.policy.decisions", "asg.policy.decision" => decision.kind())
68                .increment(1);
69        }
70        Err(e) => {
71            span.record("error.type", e.code());
72            span.record("otel.status_code", "error");
73        }
74    }
75    result
76}
77
78/// Handle bound to a `Repository` + path prefix. Mirrors the
79/// `agentstategraph-tasks` `TaskStore` pattern.
80pub struct PolicyStore {
81    repo: Arc<Repository>,
82    prefix: String,
83    agent_id: String,
84    /// Optional signature verifier. When `None` the store is in
85    /// pass-through mode and never inspects `Policy::signature`
86    /// (back-compat with pre-0.7.5 callers).
87    verifier: Option<Arc<dyn SignatureVerifier>>,
88    /// If `true` and a verifier is registered, policies without a
89    /// `signature` field are filtered out of `active()` the same way
90    /// an unratified proposal is. Defaults to `false` so unsigned
91    /// policies remain live.
92    require_signed: bool,
93    /// Optional registry of external-evaluator runners (0.7.5 §4a).
94    /// When set, policies whose `external_evaluator.kind` matches a
95    /// registered runner are dispatched through it instead of the
96    /// local evaluator. Unregistered kinds are treated as skipped.
97    external_evaluators: Option<Arc<ExternalEvaluatorRegistry>>,
98}
99
100impl PolicyStore {
101    pub fn new(
102        repo: Arc<Repository>,
103        prefix: impl Into<String>,
104        agent_id: impl Into<String>,
105    ) -> Self {
106        let mut prefix = prefix.into();
107        if prefix.ends_with('/') {
108            prefix.pop();
109        }
110        Self {
111            repo,
112            prefix,
113            agent_id: agent_id.into(),
114            verifier: None,
115            require_signed: false,
116            external_evaluators: None,
117        }
118    }
119
120    /// Install a signature verifier. Policies whose signature fails
121    /// verification are treated as not-currently-active by
122    /// [`Self::active`] and therefore ignored by `evaluate` /
123    /// `evaluate_change`. Pass-through if never called.
124    pub fn with_verifier(mut self, verifier: Arc<dyn SignatureVerifier>) -> Self {
125        self.verifier = Some(verifier);
126        self
127    }
128
129    /// When `true` (and a verifier is registered) unsigned policies
130    /// are filtered out of [`Self::active`]. Defaults to `false`.
131    ///
132    /// No-op when no verifier is registered — unsigned policies stay
133    /// live in pass-through mode.
134    pub fn with_require_signed(mut self, require_signed: bool) -> Self {
135        self.require_signed = require_signed;
136        self
137    }
138
139    /// Install an external-evaluator registry (0.7.5 §4a). Policies
140    /// whose `external_evaluator.kind` matches a registered runner are
141    /// dispatched through it inside `evaluate_scoped` /
142    /// `evaluate_change_scoped`; policies with an unregistered kind
143    /// are silently skipped (treated as not-matching). Pass-through
144    /// when never called — every policy goes through the local
145    /// evaluator.
146    pub fn with_external_evaluators(mut self, registry: Arc<ExternalEvaluatorRegistry>) -> Self {
147        self.external_evaluators = Some(registry);
148        self
149    }
150
151    pub fn prefix(&self) -> &str {
152        &self.prefix
153    }
154
155    pub fn agent_id(&self) -> &str {
156        &self.agent_id
157    }
158
159    // -----------------------------------------------------------------
160    // Write operations
161    // -----------------------------------------------------------------
162
163    /// Write a proposed policy (not yet ratified). Fails if an entry
164    /// already lives at the same path — supersede instead.
165    ///
166    /// The `path`, `version`, `proposed_by`, and `proposed_at` fields
167    /// are normalized/overridden on write: callers pass a `Policy` with
168    /// `path` set to the intended logical path (slashes, with or without
169    /// leading `/`), and the store writes version = 1, `proposed_by =
170    /// agent_id`, `proposed_at = now`, `ratified_by = None`.
171    pub fn propose(&self, ref_name: &str, mut policy: Policy) -> Result<String, PolicyError> {
172        let normalized = paths::normalize(&policy.path)?;
173        policy.path = normalized.clone();
174        policy.version = 1;
175        policy.proposed_by = self.agent_id.clone();
176        policy.proposed_at = Utc::now();
177        policy.ratified_by = None;
178        policy.ratified_at = None;
179        policy.ratification_reasoning = None;
180        policy.supersedes = None;
181        if policy.active_from.timestamp() == 0 {
182            policy.active_from = Utc::now();
183        }
184
185        let path = paths::active(&self.prefix, &normalized);
186        let value = serde_json::to_value(&policy)?;
187        // Existence check and write must see the same head, or two
188        // concurrent proposals at one path both pass the check and the second
189        // silently replaces the first.
190        for _ in 0..MAX_CAS_RETRIES {
191            let head = self.repo.head(ref_name)?;
192            if self.exists(ref_name, &normalized)? {
193                return Err(PolicyError::AlreadyExists(normalized));
194            }
195            match self.repo.set_json_cas(
196                ref_name,
197                head,
198                &path,
199                &value,
200                self.commit_opts(
201                    IntentCategory::PolicyPropose,
202                    format!("Propose policy {}", policy.handle()),
203                ),
204            ) {
205                Ok(_) => return Ok(policy.handle()),
206                Err(RepoError::WriteConflict) => continue,
207                Err(e) => return Err(e.into()),
208            }
209        }
210        Err(RepoError::WriteConflict.into())
211    }
212
213    /// Ratify an unratified proposal at `path`. Fails if the policy
214    /// does not exist, is already ratified, or the ratifier / reasoning
215    /// are empty.
216    pub fn ratify(
217        &self,
218        ref_name: &str,
219        path: &str,
220        ratifier: &str,
221        reasoning: &str,
222    ) -> Result<(), PolicyError> {
223        if ratifier.trim().is_empty() {
224            return Err(PolicyError::Invalid("ratifier required".into()));
225        }
226        let normalized = paths::normalize(path)?;
227        self.update_active(ref_name, &normalized, |policy| {
228            if policy.is_ratified() {
229                return Err(PolicyError::AlreadyRatified(policy.handle()));
230            }
231            policy.ratified_by = Some(ratifier.to_string());
232            policy.ratified_at = Some(Utc::now());
233            policy.ratification_reasoning = if reasoning.is_empty() {
234                None
235            } else {
236                Some(reasoning.to_string())
237            };
238            Ok((
239                IntentCategory::PolicyRatify,
240                format!("Ratify policy {} by {}", policy.handle(), ratifier),
241            ))
242        })
243    }
244
245    /// Replace the active policy at `path` with `new_policy`. The old
246    /// active version is moved to `history/<version>` and the new one
247    /// is written at the active path with `version = old + 1` and
248    /// `supersedes = "<path>@<old_version>"`.
249    ///
250    /// The new policy is written as already ratified iff its
251    /// `ratified_by` is `Some(_)` — callers that want propose-then-
252    /// ratify semantics on a supersede can leave it `None` and call
253    /// `ratify` separately.
254    pub fn supersede(
255        &self,
256        ref_name: &str,
257        path: &str,
258        mut new_policy: Policy,
259    ) -> Result<String, PolicyError> {
260        let normalized = paths::normalize(path)?;
261        if new_policy.active_from.timestamp() == 0 {
262            new_policy.active_from = Utc::now();
263        }
264        let active_path = paths::active(&self.prefix, &normalized);
265
266        // A supersede writes two paths from one read (the old version moves to
267        // history, the new one takes the active slot), so it lands as a
268        // speculation — committed only if nothing moved since the read. A
269        // plain `commit_speculation` would merge with the speculation winning,
270        // silently reverting a concurrent ratify or signature on the old
271        // version, and two concurrent supersedes would both claim `old + 1`.
272        for _ in 0..MAX_CAS_RETRIES {
273            let head = self.repo.head(ref_name)?;
274            let old = self.load_active(ref_name, &normalized)?;
275
276            new_policy.path = normalized.clone();
277            new_policy.version = old.version + 1;
278            new_policy.proposed_by = self.agent_id.clone();
279            new_policy.proposed_at = Utc::now();
280            new_policy.supersedes = Some(old.handle());
281
282            let history_path = paths::historical(&self.prefix, &normalized, old.version);
283            let old_value = serde_json::to_value(&old)?;
284            let new_value = serde_json::to_value(&new_policy)?;
285
286            let handle = self
287                .repo
288                .speculate(ref_name, Some(format!("Supersede {}", normalized)))?;
289            self.repo.spec_set_json(handle, &history_path, &old_value)?;
290            self.repo.spec_set_json(handle, &active_path, &new_value)?;
291            match self.repo.commit_speculation_cas(
292                handle,
293                head,
294                self.commit_opts(
295                    IntentCategory::PolicySupersede,
296                    format!("Supersede {} → {}", old.handle(), new_policy.handle()),
297                ),
298            ) {
299                Ok(_) => return Ok(new_policy.handle()),
300                Err(RepoError::WriteConflict) => continue,
301                Err(e) => return Err(e.into()),
302            }
303        }
304        Err(RepoError::WriteConflict.into())
305    }
306
307    /// Attach (or replace) a signature on the active policy at `path`.
308    ///
309    /// Called by the MCP `policy_sign` tool (§2c of the 0.7.5 plan) after
310    /// the registered `PolicySigner` has produced the signature bytes
311    /// over the canonical form. The existing active policy is loaded,
312    /// `policy.signature` is overwritten, and the result is written back
313    /// under `IntentCategory::PolicySign` so the commit log stays filterable.
314    ///
315    /// This is the only writer that may mutate `Policy::signature`
316    /// post-ratification without bumping version — signatures are
317    /// detached metadata over the canonical body and the canonical body
318    /// excludes `signature` itself.
319    pub fn set_signature(
320        &self,
321        ref_name: &str,
322        path: &str,
323        signature: PolicySignature,
324    ) -> Result<(), PolicyError> {
325        let normalized = paths::normalize(path)?;
326        self.update_active(ref_name, &normalized, |policy| {
327            policy.signature = Some(signature.clone());
328            Ok((
329                IntentCategory::PolicySign,
330                format!("Sign policy {}", policy.handle()),
331            ))
332        })
333    }
334
335    // -----------------------------------------------------------------
336    // Read operations
337    // -----------------------------------------------------------------
338
339    /// Fetch the active (ratified or proposed) policy at `path`, or a
340    /// pinned historical version when `version` is `Some(_)`.
341    pub fn get(
342        &self,
343        ref_name: &str,
344        path: &str,
345        version: Option<u64>,
346    ) -> Result<Policy, PolicyError> {
347        let normalized = paths::normalize(path)?;
348        match version {
349            None => self.load_active(ref_name, &normalized),
350            Some(v) => {
351                let current = self.load_active(ref_name, &normalized)?;
352                if current.version == v {
353                    return Ok(current);
354                }
355                let p = paths::historical(&self.prefix, &normalized, v);
356                let value = self.repo.get_json(ref_name, &p).map_err(|e| {
357                    if is_path_not_found(&e) {
358                        PolicyError::NotFound(format!("{}@{}", normalized, v))
359                    } else {
360                        e.into()
361                    }
362                })?;
363                Ok(serde_json::from_value(value)?)
364            }
365        }
366    }
367
368    /// List every policy (active versions only) whose path starts with
369    /// `prefix_filter` (path form without leading slash). `None` lists
370    /// all policies under the store prefix.
371    pub fn list(
372        &self,
373        ref_name: &str,
374        prefix_filter: Option<&str>,
375    ) -> Result<Vec<Policy>, PolicyError> {
376        self.list_scoped(ref_name, prefix_filter, None)
377    }
378
379    /// Tenant-scoped variant of [`Self::list`] (0.7.5 §3b).
380    ///
381    /// `tenant_filter` semantics:
382    /// - `None` → no filter; every policy (globals + all tenants) is visible.
383    ///   This is the back-compat path used by [`Self::list`].
384    /// - `Some(tid)` → only policies with `tenant_id == Some(tid)` or
385    ///   `tenant_id == None` (globals always apply) are returned.
386    pub fn list_scoped(
387        &self,
388        ref_name: &str,
389        prefix_filter: Option<&str>,
390        tenant_filter: Option<&str>,
391    ) -> Result<Vec<Policy>, PolicyError> {
392        let leaves = match self.repo.list_paths(ref_name, &self.prefix, None) {
393            Ok(v) => v,
394            Err(e) if is_path_not_found(&e) => return Ok(Vec::new()),
395            Err(e) => return Err(e.into()),
396        };
397        let store_prefix_with_slash = format!("{}/", self.prefix);
398        let meta_marker = format!("/{}/", paths::META_KEY);
399        let history_marker = format!("/{}/", paths::HISTORY_KEY);
400        let mut policy_paths: Vec<String> = Vec::new();
401        for leaf in &leaves {
402            let Some(rel) = leaf.strip_prefix(&store_prefix_with_slash) else {
403                continue;
404            };
405            // Skip history entries entirely.
406            if rel.contains(&history_marker) {
407                continue;
408            }
409            // Policies are stored as JSON objects at `<policy_path>/_meta`,
410            // so every leaf for the policy lives at
411            // `<policy_path>/_meta/<field...>`. Extract the policy path
412            // by taking everything before `/_meta/`.
413            let Some(idx) = rel.find(&meta_marker) else {
414                continue;
415            };
416            let policy_rel = &rel[..idx];
417            if policy_rel.is_empty() {
418                continue;
419            }
420            if let Some(filter) = prefix_filter {
421                let filter = filter.trim_start_matches('/');
422                if !policy_rel.starts_with(filter) {
423                    continue;
424                }
425            }
426            policy_paths.push(policy_rel.to_string());
427        }
428        policy_paths.sort();
429        policy_paths.dedup();
430
431        let mut out = Vec::with_capacity(policy_paths.len());
432        for p in policy_paths {
433            match self.load_active(ref_name, &p) {
434                Ok(policy) => {
435                    if !tenant_matches(&policy, tenant_filter) {
436                        continue;
437                    }
438                    out.push(policy);
439                }
440                Err(PolicyError::NotFound(_)) => continue,
441                Err(e) => return Err(e),
442            }
443        }
444        Ok(out)
445    }
446
447    /// List currently-active policies: ratified AND `active_from <= now`
448    /// AND not expired.
449    ///
450    /// A ratified policy with a future `active_from` is skipped the
451    /// same way an unratified proposal is — it's scheduled but not
452    /// yet live. When a signature verifier has been installed via
453    /// [`Self::with_verifier`], policies whose signature fails
454    /// verification — or whose signature is missing while
455    /// `require_signed` is `true` — are also filtered out with the
456    /// same not-currently-active semantics. No verifier → pass
457    /// through (back-compat).
458    pub fn active(
459        &self,
460        ref_name: &str,
461        prefix_filter: Option<&str>,
462    ) -> Result<Vec<Policy>, PolicyError> {
463        self.active_scoped(ref_name, prefix_filter, None)
464    }
465
466    /// Tenant-scoped variant of [`Self::active`] (0.7.5 §3b).
467    ///
468    /// Applies the tenant filter *after* the signature filter so
469    /// signature-rejected policies are never surfaced regardless of
470    /// the tenant. `tenant_filter` semantics match [`Self::list_scoped`]:
471    /// `None` is no filter; `Some(tid)` keeps policies with
472    /// `tenant_id == Some(tid)` OR `tenant_id == None` (globals always
473    /// apply). `policies_for_situation_scoped`, `evaluate_scoped`, and
474    /// `evaluate_change_scoped` all route through this method, so they
475    /// inherit the filter for free.
476    pub fn active_scoped(
477        &self,
478        ref_name: &str,
479        prefix_filter: Option<&str>,
480        tenant_filter: Option<&str>,
481    ) -> Result<Vec<Policy>, PolicyError> {
482        let now = Utc::now();
483        Ok(self
484            .list_scoped(ref_name, prefix_filter, tenant_filter)?
485            .into_iter()
486            .filter(|p| p.is_currently_active(now))
487            .filter(|p| self.signature_passes(p))
488            .collect())
489    }
490
491    /// Decision tree for the verifier hook. See [`Self::active`] for
492    /// semantics. Split out so `evaluate` / `evaluate_change` inherit
493    /// the exact same filter via their call to `active()` /
494    /// `policies_for_situation`.
495    fn signature_passes(&self, policy: &Policy) -> bool {
496        let Some(verifier) = self.verifier.as_ref() else {
497            // No verifier registered → pass-through.
498            return true;
499        };
500        match policy.signature.as_ref() {
501            None => !self.require_signed,
502            Some(_) => verifier.verify_policy(policy).is_ok(),
503        }
504    }
505
506    /// Walk the supersedes chain. Returned oldest-first (version 1 →
507    /// current).
508    pub fn history(&self, ref_name: &str, path: &str) -> Result<Vec<Policy>, PolicyError> {
509        let normalized = paths::normalize(path)?;
510        let current = self.load_active(ref_name, &normalized)?;
511        let mut versions = vec![current.clone()];
512        let mut v = current.version;
513        while v > 1 {
514            v -= 1;
515            let hp = paths::historical(&self.prefix, &normalized, v);
516            let value = self.repo.get_json(ref_name, &hp).map_err(|e| {
517                if is_path_not_found(&e) {
518                    PolicyError::NotFound(format!("{}@{}", normalized, v))
519                } else {
520                    PolicyError::from(e)
521                }
522            })?;
523            let policy: Policy = serde_json::from_value(value)?;
524            versions.push(policy);
525        }
526        versions.reverse();
527        Ok(versions)
528    }
529
530    /// Every ratified policy whose `situation_selector` matches `situation`.
531    pub fn policies_for_situation(
532        &self,
533        ref_name: &str,
534        situation: &Situation,
535    ) -> Result<Vec<Policy>, PolicyError> {
536        self.policies_for_situation_scoped(ref_name, situation, None)
537    }
538
539    /// Tenant-scoped variant of [`Self::policies_for_situation`]
540    /// (0.7.5 §3b). Delegates to [`Self::active_scoped`] so the tenant
541    /// filter is inherited.
542    pub fn policies_for_situation_scoped(
543        &self,
544        ref_name: &str,
545        situation: &Situation,
546        tenant_filter: Option<&str>,
547    ) -> Result<Vec<Policy>, PolicyError> {
548        Ok(self
549            .active_scoped(ref_name, None, tenant_filter)?
550            .into_iter()
551            .filter(|p| p.situation_selector.matches(situation))
552            .collect())
553    }
554
555    // -----------------------------------------------------------------
556    // Evaluation
557    // -----------------------------------------------------------------
558
559    /// Authorization evaluation — POLICY_V1.md §5.
560    pub fn evaluate(
561        &self,
562        ref_name: &str,
563        situation: &Situation,
564        action: &str,
565        agent_id: &str,
566    ) -> Result<Decision, PolicyError> {
567        self.evaluate_scoped(ref_name, situation, action, agent_id, None)
568    }
569
570    /// Tenant-scoped variant of [`Self::evaluate`] (0.7.5 §3b). Routes
571    /// through [`Self::policies_for_situation_scoped`] so only policies
572    /// with `tenant_id == Some(tid)` or `tenant_id == None` contribute
573    /// to the decision.
574    ///
575    /// External-evaluator dispatch (0.7.5 §4a): matched policies carrying
576    /// an `external_evaluator` whose kind is registered in the store's
577    /// registry are routed to the external runner; their decisions are
578    /// combined with the local evaluator's verdict under the existing
579    /// `deny > require_approval > allow` precedence. Policies whose
580    /// external kind is not registered are skipped.
581    pub fn evaluate_scoped(
582        &self,
583        ref_name: &str,
584        situation: &Situation,
585        action: &str,
586        agent_id: &str,
587        tenant_filter: Option<&str>,
588    ) -> Result<Decision, PolicyError> {
589        evaluation(evaluation_span!("asg.policy.evaluate"), || {
590            let matched = self.policies_for_situation_scoped(ref_name, situation, tenant_filter)?;
591            let (locals, externals) = self.partition_for_dispatch(&matched);
592
593            let external_decisions = self.run_externals(&externals, situation, action, agent_id);
594            let local_decision = evaluator::evaluate_matched(&locals, action, agent_id);
595
596            Ok(combine_decisions(external_decisions, local_decision))
597        })
598    }
599
600    /// Change-proposal evaluation — POLICY_V1.md §22.2.
601    pub fn evaluate_change(
602        &self,
603        ref_name: &str,
604        proposal: &ChangeProposal,
605    ) -> Result<Decision, PolicyError> {
606        self.evaluate_change_scoped(ref_name, proposal, None)
607    }
608
609    /// Tenant-scoped variant of [`Self::evaluate_change`] (0.7.5 §3b).
610    /// Routes through [`Self::active_scoped`].
611    ///
612    /// External-evaluator dispatch (0.7.5 §4a): matched policies carrying
613    /// an `external_evaluator` whose kind is registered in the store's
614    /// registry are routed to the external runner for *their* portion of
615    /// the decision; their decisions are combined with the local
616    /// change-evaluator's verdict under `deny > require_approval > allow`
617    /// precedence. Policies whose external kind is not registered are
618    /// skipped. External runners see `proposal.action` / `proposal.agent_id`
619    /// and an empty [`Situation`] — the change-proposal flow does not
620    /// carry a situation context of its own.
621    pub fn evaluate_change_scoped(
622        &self,
623        ref_name: &str,
624        proposal: &ChangeProposal,
625        tenant_filter: Option<&str>,
626    ) -> Result<Decision, PolicyError> {
627        evaluation(evaluation_span!("asg.policy.evaluate_change"), || {
628            let actives = self.active_scoped(ref_name, None, tenant_filter)?;
629            let (locals, externals) = self.partition_for_dispatch(&actives);
630
631            // External runners don't consume `ChangeProposal` (that's a
632            // policy-crate-specific concept). Route them through
633            // `evaluate(action, agent_id)` against an empty situation; this
634            // mirrors what a Rego / Cedar runner would see in §4b.
635            let empty_situation = Situation::new();
636            let external_decisions = self.run_externals(
637                &externals,
638                &empty_situation,
639                &proposal.action,
640                &proposal.agent_id,
641            );
642            let local_decision = evaluator::evaluate_change(&locals, proposal);
643
644            Ok(combine_decisions(external_decisions, local_decision))
645        })
646    }
647
648    // -----------------------------------------------------------------
649    // External-evaluator dispatch helpers (0.7.5 §4a)
650    // -----------------------------------------------------------------
651
652    /// Split `policies` into `(local, external)` by inspecting each
653    /// policy's `external_evaluator` field against the registered
654    /// runner kinds.
655    ///
656    /// - Policies with `external_evaluator == None` go into `local` —
657    ///   they are evaluated by the local evaluator.
658    /// - Policies with `external_evaluator == Some(ref_)` where
659    ///   `ref_.kind()` is present in the registry go into `external`.
660    /// - Policies with an external ref whose kind is NOT registered
661    ///   are dropped (skipped; treated as not-matching).
662    /// - If no registry is installed, every policy falls through the
663    ///   local path regardless of the `external_evaluator` field.
664    fn partition_for_dispatch<'a>(
665        &self,
666        policies: &'a [Policy],
667    ) -> (Vec<&'a Policy>, Vec<&'a Policy>) {
668        let Some(registry) = self.external_evaluators.as_ref() else {
669            // No registry → everything is local regardless of field.
670            return (policies.iter().collect(), Vec::new());
671        };
672        let mut locals = Vec::with_capacity(policies.len());
673        let mut externals = Vec::new();
674        for p in policies {
675            match p.external_evaluator.as_ref() {
676                None => locals.push(p),
677                Some(ref_) => {
678                    if registry.get(external_kind(ref_)).is_some() {
679                        externals.push(p);
680                    }
681                    // else: unregistered kind → skip
682                }
683            }
684        }
685        (locals, externals)
686    }
687
688    /// Invoke each external policy's runner and collect the decisions.
689    /// A policy whose runner errors is treated like an unregistered kind
690    /// (skipped), preserving the POLICY_V1.md §11 soft-model guarantee that
691    /// a misbehaving runner never crashes evaluation; the error is logged
692    /// and counted in `asg.policy.evaluator.errors`.
693    fn run_externals(
694        &self,
695        policies: &[&Policy],
696        situation: &Situation,
697        action: &str,
698        agent_id: &str,
699    ) -> Vec<Decision> {
700        let Some(registry) = self.external_evaluators.as_ref() else {
701            return Vec::new();
702        };
703        let mut out = Vec::with_capacity(policies.len());
704        for p in policies {
705            let Some(ref_) = p.external_evaluator.as_ref() else {
706                continue;
707            };
708            let Some(runner) = registry.get(external_kind(ref_)) else {
709                continue;
710            };
711            let source = external_source(ref_);
712            let kind = runner.kind();
713            let span = evaluation_span!("asg.policy.external", asg.policy.evaluator = kind);
714            let _entered = span.enter();
715            match runner.evaluate(source, situation, action, agent_id) {
716                Ok(d) => {
717                    span.record("asg.policy.decision", d.kind());
718                    out.push(d);
719                }
720                // Skip this policy. The error's message isn't recorded: an
721                // evaluator's output can echo its input.
722                Err(e) => {
723                    span.record("error.type", e.code());
724                    span.record("otel.status_code", "error");
725                    describe_metrics();
726                    metrics::counter!(
727                        "asg.policy.evaluator.errors",
728                        "asg.policy.evaluator" => kind,
729                        "error.type" => e.code()
730                    )
731                    .increment(1);
732                    tracing::warn!(
733                        asg.policy.evaluator = kind,
734                        asg.policy.id = %p.handle(),
735                        error.type = e.code(),
736                        "an external policy evaluator failed, so its policy was skipped"
737                    );
738                }
739            }
740        }
741        out
742    }
743
744    // -----------------------------------------------------------------
745    // Internals
746    // -----------------------------------------------------------------
747
748    fn exists(&self, ref_name: &str, normalized: &str) -> Result<bool, PolicyError> {
749        match self.load_active(ref_name, normalized) {
750            Ok(_) => Ok(true),
751            Err(PolicyError::NotFound(_)) => Ok(false),
752            Err(e) => Err(e),
753        }
754    }
755
756    /// Read-modify-write the active policy at `normalized` without losing a
757    /// concurrent update: snapshot the head, read, apply `change` (which
758    /// returns the commit's intent and description), and land it with
759    /// `set_json_cas` against that head, re-reading on conflict. A plain
760    /// `set_json` wrote back the value read earlier, so a concurrent ratify
761    /// and sign reverted one another while both returned Ok.
762    fn update_active<F>(
763        &self,
764        ref_name: &str,
765        normalized: &str,
766        mut change: F,
767    ) -> Result<(), PolicyError>
768    where
769        F: FnMut(&mut Policy) -> Result<(IntentCategory, String), PolicyError>,
770    {
771        let active_path = paths::active(&self.prefix, normalized);
772        for _ in 0..MAX_CAS_RETRIES {
773            let head = self.repo.head(ref_name)?;
774            let mut policy = self.load_active(ref_name, normalized)?;
775            let (category, description) = change(&mut policy)?;
776            let value = serde_json::to_value(&policy)?;
777            match self.repo.set_json_cas(
778                ref_name,
779                head,
780                &active_path,
781                &value,
782                self.commit_opts(category, description),
783            ) {
784                Ok(_) => return Ok(()),
785                Err(RepoError::WriteConflict) => continue,
786                Err(e) => return Err(e.into()),
787            }
788        }
789        Err(RepoError::WriteConflict.into())
790    }
791
792    fn load_active(&self, ref_name: &str, normalized: &str) -> Result<Policy, PolicyError> {
793        let path = paths::active(&self.prefix, normalized);
794        let value = self.repo.get_json(ref_name, &path).map_err(|e| {
795            if is_path_not_found(&e) {
796                PolicyError::NotFound(normalized.to_string())
797            } else {
798                PolicyError::from(e)
799            }
800        })?;
801        Ok(serde_json::from_value(value)?)
802    }
803
804    fn commit_opts(
805        &self,
806        category: IntentCategory,
807        description: impl Into<String>,
808    ) -> CommitOptions {
809        CommitOptions::new(&self.agent_id, category, description)
810    }
811}
812
813fn is_path_not_found(e: &agentstategraph::RepoError) -> bool {
814    matches!(e, agentstategraph::RepoError::Tree(_))
815}
816
817/// Kind tag for an [`ExternalEvaluatorRef`] — matches the runner's
818/// `ExternalEvaluator::kind()` return for lookup in the registry.
819fn external_kind(ref_: &ExternalEvaluatorRef) -> &'static str {
820    match ref_ {
821        ExternalEvaluatorRef::Rego { .. } => "rego",
822        ExternalEvaluatorRef::Cedar { .. } => "cedar",
823        ExternalEvaluatorRef::Wasm { .. } => "wasm",
824    }
825}
826
827/// Extract the `source` field out of an [`ExternalEvaluatorRef`].
828fn external_source(ref_: &ExternalEvaluatorRef) -> &crate::types::EvaluatorSource {
829    match ref_ {
830        ExternalEvaluatorRef::Rego { source }
831        | ExternalEvaluatorRef::Cedar { source }
832        | ExternalEvaluatorRef::Wasm { source } => source,
833    }
834}
835
836/// Combine decisions from external runners with the local evaluator's
837/// verdict under the existing `deny > require_approval > allow`
838/// precedence (POLICY_V1.md §5).
839///
840/// External policies' decisions are considered *before* the local
841/// decision at each precedence band so a deny from an external policy
842/// beats an allow from the local path (and vice versa — a local deny
843/// also beats an external allow). Within external decisions, first-win
844/// at each band is enough because the final precedence ladder
845/// (deny → approval → allow) is applied across the full set.
846fn combine_decisions(externals: Vec<Decision>, local: Decision) -> Decision {
847    let all: Vec<Decision> = externals
848        .into_iter()
849        .chain(std::iter::once(local))
850        .collect();
851
852    // Pass 1: any Deny wins.
853    for d in &all {
854        if matches!(d, Decision::Deny { .. }) {
855            return d.clone();
856        }
857    }
858    // Pass 2: any RequireApproval wins over Allow / NoPolicyMatch.
859    for d in &all {
860        if matches!(d, Decision::RequireApproval { .. }) {
861            return d.clone();
862        }
863    }
864    // Pass 3: any Allow wins over NoPolicyMatch.
865    for d in &all {
866        if matches!(d, Decision::Allow { .. }) {
867            return d.clone();
868        }
869    }
870    Decision::NoPolicyMatch
871}
872
873/// Tenant-scope predicate shared by every `_scoped` read path (0.7.5 §3b).
874///
875/// - `tenant_filter == None` → unconditional pass (back-compat).
876/// - `tenant_filter == Some(tid)` → keep policies with
877///   `tenant_id == None` (globals always apply) OR
878///   `tenant_id == Some(tid)`. Policies scoped to a different tenant
879///   are filtered out the same way an unratified proposal is.
880fn tenant_matches(policy: &Policy, tenant_filter: Option<&str>) -> bool {
881    match tenant_filter {
882        None => true,
883        Some(tid) => match policy.tenant_id.as_deref() {
884            None => true,
885            Some(p_tid) => p_tid == tid,
886        },
887    }
888}