Skip to main content

areev_loop_adapter/
governance.rs

1//! The real [`GovernanceHost`] — CAL's loop lifecycle statements executed
2//! through the Areev Loop engine (CAL 1.3 §8.16).
3//!
4//! This crate knows both sides of the seam: `areev-cal` (the facade the
5//! executor runs against) and `areev-loop` (the engine). Every call receives
6//! the executor's own facade — never a second store handle, which the
7//! single-writer registry would refuse — and derives the session identity
8//! from it: actor = the bound principal, scopes = [`crate::scopes_for`],
9//! observer = [`crate::observer_for_principal`]. A statement cannot claim
10//! to be someone; the four gates run in the engine exactly as on every
11//! other surface, including the co-creator self-approval block (the
12//! session principal rides `RunOptions::triggering_actor`).
13
14use areev_cal::governance::{GovernanceHost, LoopInfo, ReviewDecision, RunLoopOptions};
15use areev_cal::{CalStoreFacade, AreevFacade};
16use areev_core::error::{AreevError, Result};
17use areev_loop::{Decision, Engine, Policy, RunOptions};
18
19/// Governance over `Engine::with_builtins()` plus an optional host policy.
20/// LLM backends deliberately have no seam here: `RUN LOOP` from CAL is the
21/// deterministic pass — model-attached runs stay on the host surfaces where
22/// credentials live.
23pub struct LoopGovernance {
24    policy: Option<Policy>,
25}
26
27impl LoopGovernance {
28    pub fn new() -> Self {
29        LoopGovernance { policy: None }
30    }
31
32    /// Attach the same `loop-policy.json` the CLI takes (`--policy`) — a
33    /// CAL-triggered run honors the same auto-apply grants, denies, and
34    /// severity floors.
35    pub fn with_policy(policy: Policy) -> Self {
36        LoopGovernance { policy: Some(policy) }
37    }
38
39    fn engine(&self) -> Engine {
40        match &self.policy {
41            Some(p) => Engine::with_builtins().with_policy(p.clone()),
42            None => Engine::with_builtins(),
43        }
44    }
45
46    /// The concrete facade back from the trait object. Only the real
47    /// Areev facade can host governance — a mock or foreign store answers
48    /// `None` from `as_any` and gets a clean refusal.
49    fn facade<'a>(&self, store: &'a dyn CalStoreFacade) -> Result<&'a AreevFacade> {
50        store
51            .as_any()
52            .and_then(|a| a.downcast_ref::<AreevFacade>())
53            .ok_or_else(|| {
54                AreevError::Internal(
55                    "governance requires the Areev facade — this store cannot host the loop"
56                        .into(),
57                )
58            })
59    }
60
61    fn wrap(e: areev_loop::Error) -> AreevError {
62        // The engine's LOP-Ennn code is the leading token of its Display —
63        // keep it visible through the wrap.
64        AreevError::Validation(e.to_string())
65    }
66}
67
68impl Default for LoopGovernance {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74impl GovernanceHost for LoopGovernance {
75    fn run_loop(
76        &self,
77        store: &dyn CalStoreFacade,
78        opts: &RunLoopOptions,
79    ) -> Result<serde_json::Value> {
80        let facade = self.facade(store)?;
81        // A run analyzes the whole memory and writes recommendation, audit,
82        // and loop-state grains, so it is gated exactly as `POST
83        // /api/loop/run` is: `loop.run` on the loop namespace (owner
84        // passes). Without this the CAL surface is a way around the HTTP
85        // gate — a read-only principal refused at the route could still
86        // write grains via `RUN LOOP`.
87        facade
88            .authz()
89            .check(areev_core::authz::Verb::LoopRun, areev_loop::LOOP_NS)?;
90        let mut sub = crate::BorrowedSubstrate::new(facade);
91        let run_opts = RunOptions {
92            min_new: opts.min_new,
93            if_stale_ms: opts.if_stale_ms,
94            full_sweep: opts.full_sweep,
95            // The session principal is the trigger — the self-approval
96            // block extends to whatever this run's external analyzers
97            // author (Builtin findings stay engine-created).
98            triggering_actor: Some(facade.authz().principal().to_string()),
99            ..RunOptions::default()
100        };
101        let res = self
102            .engine()
103            .run(&mut sub, &run_opts, crate::now_ms())
104            .map_err(Self::wrap)?;
105        serde_json::to_value(&res).map_err(|e| AreevError::Internal(e.to_string()))
106    }
107
108    fn review(
109        &self,
110        store: &dyn CalStoreFacade,
111        rec_hash: &str,
112        decision: ReviewDecision,
113        because: &str,
114    ) -> Result<()> {
115        let facade = self.facade(store)?;
116        let actor = facade.authz().principal().to_string();
117        let scopes = crate::scopes_for(&facade.authz());
118        let observer = crate::observer_for_principal(&actor);
119        let mut sub = crate::BorrowedSubstrate::new(facade);
120        let d = match decision {
121            ReviewDecision::Approve => Decision::Approve,
122            ReviewDecision::Reject => Decision::Reject,
123        };
124        self.engine()
125            .review(&mut sub, rec_hash, d, &actor, observer, &scopes, because, crate::now_ms())
126            .map_err(Self::wrap)
127    }
128
129    fn apply(
130        &self,
131        store: &dyn CalStoreFacade,
132        rec_hash: &str,
133        because: &str,
134        destructive_cap: bool,
135    ) -> Result<bool> {
136        let facade = self.facade(store)?;
137        let authz = facade.authz();
138        let actor = authz.principal().to_string();
139        let scopes = crate::scopes_for(&authz);
140        let observer = crate::observer_for_principal(&actor);
141        // The two-key rule, verb-shaped: a destructive apply needs
142        // `loop.apply` (in scopes) AND the session's own destruction verbs —
143        // and, above both, the process-wide cap, which is restrictive over
144        // any grant. A `--no-destructive-ops` session must not be able to
145        // route a FORGET through a recommendation.
146        let allow_destructive = destructive_cap
147            && (authz.allows(areev_core::authz::Verb::Delete, "*")
148                || authz.allows(areev_core::authz::Verb::Erase, "*"));
149        let mut sub = crate::BorrowedSubstrate::new(facade);
150        let applied = self
151            .engine()
152            .apply(
153                &mut sub,
154                rec_hash,
155                &actor,
156                observer,
157                &scopes,
158                because,
159                allow_destructive,
160                crate::now_ms(),
161            )
162            .map_err(Self::wrap)?;
163        Ok(applied.rollbackable)
164    }
165
166    fn rollback(&self, store: &dyn CalStoreFacade, rec_hash: &str, because: &str) -> Result<()> {
167        let facade = self.facade(store)?;
168        let actor = facade.authz().principal().to_string();
169        let scopes = crate::scopes_for(&facade.authz());
170        let observer = crate::observer_for_principal(&actor);
171        let mut sub = crate::BorrowedSubstrate::new(facade);
172        self.engine()
173            .rollback(&mut sub, rec_hash, &actor, observer, &scopes, because, crate::now_ms())
174            .map_err(Self::wrap)
175    }
176
177    fn describe(&self, store: &dyn CalStoreFacade, what: LoopInfo) -> Result<serde_json::Value> {
178        let facade = self.facade(store)?;
179        // The loop reads live in the loop's namespace — the same read gate
180        // SHOW GRANTS uses for authz: read on areev-loop (owner passes).
181        facade
182            .authz()
183            .check(areev_core::authz::Verb::Read, areev_loop::LOOP_NS)?;
184        let sub = crate::BorrowedSubstrate::new(facade);
185        let engine = self.engine();
186        let v = match what {
187            LoopInfo::Loop => {
188                // Health plus the pending queue itself — `DESCRIBE LOOP` is
189                // the in-language `areev loop list`, so the hashes a
190                // reviewer needs for APPROVE/APPLY are right here.
191                let health = engine.health(&sub, crate::now_ms()).map_err(Self::wrap)?;
192                let pending = engine
193                    .recommendations(&sub, Some(areev_loop::RecStatus::Pending))
194                    .map_err(Self::wrap)?;
195                serde_json::to_value(health).map(|mut v| {
196                    v["pending_recommendations"] = serde_json::Value::Array(
197                        pending
198                            .iter()
199                            .map(|r| {
200                                serde_json::json!({
201                                    "hash": r.hash,
202                                    "analyzer": r.analyzer,
203                                    "severity": format!("{:?}", r.severity).to_lowercase(),
204                                    "summary": r.summary.render(),
205                                })
206                            })
207                            .collect(),
208                    );
209                    v
210                })
211            }
212            LoopInfo::Analyzers => Ok(serde_json::Value::Array(
213                engine
214                    .analyzers()
215                    .iter()
216                    .map(|a| {
217                        let m = a.manifest();
218                        serde_json::json!({
219                            "id": m.id,
220                            "family": m.family(),
221                            "default_on": m.default_on,
222                        })
223                    })
224                    .collect(),
225            )),
226            LoopInfo::Outcomes => {
227                serde_json::to_value(engine.outcomes(&sub).map_err(Self::wrap)?)
228            }
229            LoopInfo::Policy => Ok(match &self.policy {
230                None => serde_json::json!({
231                    "attached": false,
232                    "note": "no host policy attached — the closed default: nothing \
233                             auto-applies, nothing is denied",
234                }),
235                Some(p) => serde_json::json!({
236                    "attached": true,
237                    "auto_apply_enabled": p.auto_apply_enabled,
238                    "denied_families": p.deny,
239                }),
240            }),
241        };
242        v.map_err(|e| AreevError::Internal(e.to_string()))
243    }
244}