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}