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    /// The scopes a session holds over ONE recommendation (#312).
29    ///
30    /// Resolved from the recommendation's stamped `scope`, so a reviewer who
31    /// may not read the namespace a finding was derived from cannot review,
32    /// apply or roll it back either. A recommendation the caller does not
33    /// cover answers NOT FOUND rather than "not authorized": naming it would
34    /// disclose that a finding exists in a namespace they cannot see.
35    fn scopes_over(
36        &self,
37        facade: &areev_cal::AreevFacade,
38        sub: &mut crate::BorrowedSubstrate<'_>,
39        rec_hash: &str,
40    ) -> Result<areev_loop::ScopeSet> {
41        let authz = facade.authz();
42        let scope = self
43            .engine()
44            .recommendations(sub, None)
45            .ok()
46            .and_then(|recs| recs.into_iter().find(|r| r.hash == rec_hash))
47            .map(|r| r.scope)
48            .unwrap_or_default();
49        if !crate::covers_rec(&authz, &scope) {
50            return Err(Self::wrap(areev_loop::Error::NotFound(rec_hash.into())));
51        }
52        Ok(crate::scopes_for_rec(&authz, &scope))
53    }
54
55
56    pub fn new() -> Self {
57        LoopGovernance { policy: None }
58    }
59
60    /// Attach the same `loop-policy.json` the CLI takes (`--policy`) — a
61    /// CAL-triggered run honors the same auto-apply grants, denies, and
62    /// severity floors.
63    pub fn with_policy(policy: Policy) -> Self {
64        LoopGovernance { policy: Some(policy) }
65    }
66
67    fn engine(&self) -> Engine {
68        match &self.policy {
69            Some(p) => Engine::with_builtins().with_policy(p.clone()),
70            None => Engine::with_builtins(),
71        }
72    }
73
74    /// The concrete facade back from the trait object. Only the real
75    /// Areev facade can host governance — a mock or foreign store answers
76    /// `None` from `as_any` and gets a clean refusal.
77    fn facade<'a>(&self, store: &'a dyn CalStoreFacade) -> Result<&'a AreevFacade> {
78        store
79            .as_any()
80            .and_then(|a| a.downcast_ref::<AreevFacade>())
81            .ok_or_else(|| {
82                AreevError::Internal(
83                    "governance requires the Areev facade — this store cannot host the loop"
84                        .into(),
85                )
86            })
87    }
88
89    fn wrap(e: areev_loop::Error) -> AreevError {
90        // The engine's LOP-Ennn code is the leading token of its Display —
91        // keep it visible through the wrap.
92        AreevError::Validation(e.to_string())
93    }
94}
95
96impl Default for LoopGovernance {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102impl GovernanceHost for LoopGovernance {
103    fn run_loop(
104        &self,
105        store: &dyn CalStoreFacade,
106        opts: &RunLoopOptions,
107    ) -> Result<serde_json::Value> {
108        let facade = self.facade(store)?;
109        // A run analyzes the whole memory and writes recommendation, audit,
110        // and loop-state grains, so it is gated exactly as `POST
111        // /api/loop/run` is: `loop.run` on the loop namespace (owner
112        // passes). Without this the CAL surface is a way around the HTTP
113        // gate — a read-only principal refused at the route could still
114        // write grains via `RUN LOOP`.
115        facade
116            .authz()
117            .check(areev_core::authz::Verb::LoopRun, areev_loop::LOOP_NS)?;
118        let mut sub = crate::BorrowedSubstrate::new(facade);
119        let run_opts = RunOptions {
120            min_new: opts.min_new,
121            if_stale_ms: opts.if_stale_ms,
122            full_sweep: opts.full_sweep,
123            // The session principal is the trigger — the self-approval
124            // block extends to whatever this run's external analyzers
125            // author (Builtin findings stay engine-created).
126            triggering_actor: Some(facade.authz().principal().to_string()),
127            ..RunOptions::default()
128        };
129        let res = self
130            .engine()
131            .run(&mut sub, &run_opts, crate::now_ms())
132            .map_err(Self::wrap)?;
133        serde_json::to_value(&res).map_err(|e| AreevError::Internal(e.to_string()))
134    }
135
136
137    fn review(
138        &self,
139        store: &dyn CalStoreFacade,
140        rec_hash: &str,
141        decision: ReviewDecision,
142        because: &str,
143    ) -> Result<()> {
144        let facade = self.facade(store)?;
145        let actor = facade.authz().principal().to_string();
146        let observer = crate::observer_for_principal(&actor);
147        let mut sub = crate::BorrowedSubstrate::new(facade);
148        let scopes = self.scopes_over(facade, &mut sub, rec_hash)?;
149        let d = match decision {
150            ReviewDecision::Approve => Decision::Approve,
151            ReviewDecision::Reject => Decision::Reject,
152        };
153        self.engine()
154            .review(&mut sub, rec_hash, d, &actor, observer, &scopes, because, crate::now_ms())
155            .map_err(Self::wrap)
156    }
157
158    fn apply(
159        &self,
160        store: &dyn CalStoreFacade,
161        rec_hash: &str,
162        because: &str,
163        destructive_cap: bool,
164    ) -> Result<bool> {
165        let facade = self.facade(store)?;
166        let authz = facade.authz();
167        let actor = authz.principal().to_string();
168        let observer = crate::observer_for_principal(&actor);
169        // The two-key rule, verb-shaped: a destructive apply needs
170        // `loop.apply` (in scopes) AND the session's own destruction verbs —
171        // and, above both, the process-wide cap, which is restrictive over
172        // any grant. A `--no-destructive-ops` session must not be able to
173        // route a FORGET through a recommendation.
174        let allow_destructive = destructive_cap
175            && (authz.allows(areev_core::authz::Verb::Delete, "*")
176                || authz.allows(areev_core::authz::Verb::Erase, "*"));
177        let mut sub = crate::BorrowedSubstrate::new(facade);
178        let scopes = self.scopes_over(facade, &mut sub, rec_hash)?;
179        let applied = self
180            .engine()
181            .apply(
182                &mut sub,
183                rec_hash,
184                &actor,
185                observer,
186                &scopes,
187                because,
188                allow_destructive,
189                crate::now_ms(),
190            )
191            .map_err(Self::wrap)?;
192        Ok(applied.rollbackable)
193    }
194
195    fn rollback(&self, store: &dyn CalStoreFacade, rec_hash: &str, because: &str) -> Result<()> {
196        let facade = self.facade(store)?;
197        let actor = facade.authz().principal().to_string();
198        let observer = crate::observer_for_principal(&actor);
199        let mut sub = crate::BorrowedSubstrate::new(facade);
200        let scopes = self.scopes_over(facade, &mut sub, rec_hash)?;
201        self.engine()
202            .rollback(&mut sub, rec_hash, &actor, observer, &scopes, because, crate::now_ms())
203            .map_err(Self::wrap)
204    }
205
206    fn describe(&self, store: &dyn CalStoreFacade, what: LoopInfo) -> Result<serde_json::Value> {
207        let facade = self.facade(store)?;
208        // The loop reads live in the loop's namespace — the same read gate
209        // SHOW GRANTS uses for authz: read on areev-loop (owner passes).
210        facade
211            .authz()
212            .check(areev_core::authz::Verb::Read, areev_loop::LOOP_NS)?;
213        let authz = facade.authz();
214        let sub = crate::BorrowedSubstrate::new(facade);
215        let engine = self.engine();
216        let v = match what {
217            LoopInfo::Loop => {
218                // Health plus the pending queue itself — `DESCRIBE LOOP` is
219                // the in-language `areev loop list`, so the hashes a
220                // reviewer needs for APPROVE/APPLY are right here.
221                let health = engine.health(&sub, crate::now_ms()).map_err(Self::wrap)?;
222                // Filtered by coverage (#312): a reviewer sees the findings
223                // derived from namespaces they may read, and no others.
224                let pending = crate::visible_recommendations(
225                    &engine,
226                    &sub,
227                    &authz,
228                    Some(areev_loop::RecStatus::Pending),
229                )
230                .map_err(Self::wrap)?;
231                serde_json::to_value(health).map(|mut v| {
232                    v["pending_recommendations"] = serde_json::Value::Array(
233                        pending
234                            .iter()
235                            .map(|r| {
236                                serde_json::json!({
237                                    "hash": r.hash,
238                                    "analyzer": r.analyzer,
239                                    "severity": format!("{:?}", r.severity).to_lowercase(),
240                                    "summary": r.summary.render(),
241                                })
242                            })
243                            .collect(),
244                    );
245                    v
246                })
247            }
248            LoopInfo::Analyzers => Ok(serde_json::Value::Array(
249                engine
250                    .analyzers()
251                    .iter()
252                    .map(|a| {
253                        let m = a.manifest();
254                        serde_json::json!({
255                            "id": m.id,
256                            "family": m.family(),
257                            "default_on": m.default_on,
258                        })
259                    })
260                    .collect(),
261            )),
262            LoopInfo::Outcomes => {
263                serde_json::to_value(engine.outcomes(&sub).map_err(Self::wrap)?)
264            }
265            LoopInfo::Policy => Ok(match &self.policy {
266                None => serde_json::json!({
267                    "attached": false,
268                    "note": "no host policy attached — the closed default: nothing \
269                             auto-applies, nothing is denied",
270                }),
271                Some(p) => serde_json::json!({
272                    "attached": true,
273                    "auto_apply_enabled": p.auto_apply_enabled,
274                    "denied_families": p.deny,
275                }),
276            }),
277        };
278        v.map_err(|e| AreevError::Internal(e.to_string()))
279    }
280}