Skip to main content

areev_loop_adapter/
lib.rs

1//! # areev-loop-adapter
2//!
3//! The Areev substrate adapter for the [`areev_loop`] engine. It implements
4//! [`areev_loop::OmsSubstrate`] over [`areev_cal::AreevFacade`], so the governed
5//! self-improvement loop runs against real Areev `.mg`/Turso memory files.
6//!
7//! ```no_run
8//! use areev_loop_adapter::{AreevSubstrate, now_ms};
9//! use areev_store::Areev;
10//! use areev_loop::{Engine, RunOptions};
11//!
12//! let store = Areev::open("agent.db").unwrap();
13//! let mut sub = AreevSubstrate::new(store, None);
14//! let engine = Engine::with_builtins();
15//! let result = engine.run(&mut sub, &RunOptions::default(), now_ms()).unwrap();
16//! println!("proposed {} recommendation(s)", result.stored);
17//! ```
18
19mod governance;
20mod substrate;
21
22pub use governance::LoopGovernance;
23pub use substrate::{loop_state_of, AreevSubstrate, BorrowedSubstrate};
24
25use std::time::{SystemTime, UNIX_EPOCH};
26
27/// Map a session's grants onto the loop engine's scope set — the one
28/// translation between Areev's verb model (`areev_core::authz`) and
29/// `areev_loop::Scope`, so surfaces stop handing out `ScopeSet::all()`
30/// unconditionally. Loop verbs are checked against the loop's own namespace
31/// (`areev_loop::LOOP_NS`), which a grant covers by naming it or `*`.
32/// An owner session maps to every scope — the CLI's local-root-of-trust
33/// behavior, unchanged.
34pub fn scopes_for(authz: &areev_core::authz::AuthzSet) -> areev_loop::ScopeSet {
35    use areev_loop::Scope;
36    use areev_loop::LOOP_NS;
37    use areev_core::authz::Verb;
38    let mut scopes = Vec::new();
39    for (verb, scope) in [
40        (Verb::Read, Scope::Read),
41        (Verb::Write, Scope::Write),
42        (Verb::LoopReview, Scope::Review),
43        (Verb::LoopApply, Scope::Apply),
44        (Verb::Admin, Scope::Admin),
45    ] {
46        if authz.allows(verb, LOOP_NS) {
47            scopes.push(scope);
48        }
49    }
50    areev_loop::ScopeSet::of(&scopes)
51}
52
53/// The scopes a session holds over ONE recommendation, given the namespaces
54/// it was derived from (#312).
55///
56/// The loop's reads are namespace-grant-gated; its outputs were not — every
57/// recommendation went to one namespace, `areev-loop`, and rights were
58/// checked against that one namespace. So one `read ON areev-loop` grant
59/// disclosed the summary, proposal, guidance and evidence hashes of findings
60/// derived from every namespace in the memory, and one `loop.review` grant
61/// decided all of them, striking a memory-wide cooldown each time.
62///
63/// The rule: a principal covers a recommendation when its grants allow the
64/// verb on EVERY namespace in the recommendation's scope.
65///
66/// Two deliberate escape hatches, both of which keep existing deployments
67/// working unchanged:
68///
69/// * A grant on `areev-loop` itself (or `*`) means the WHOLE queue, so owner
70///   sessions and today's operator grants behave exactly as before.
71/// * An EMPTY scope — an unscoped pass, and every recommendation written
72///   before this existed — is covered only by such a whole-queue grant. Fail
73///   closed: "derived from we-don't-know-where" must not be readable by
74///   someone holding one namespace.
75pub fn scopes_for_rec(
76    authz: &areev_core::authz::AuthzSet,
77    scope: &[String],
78) -> areev_loop::ScopeSet {
79    use areev_core::authz::Verb;
80    use areev_loop::Scope;
81    use areev_loop::LOOP_NS;
82    // The whole-queue grant short-circuits: unchanged behaviour.
83    let whole_queue = scopes_for(authz);
84    if scope.is_empty() {
85        return whole_queue;
86    }
87    let mut scopes = Vec::new();
88    for (verb, s) in [
89        (Verb::Read, Scope::Read),
90        (Verb::Write, Scope::Write),
91        (Verb::LoopReview, Scope::Review),
92        (Verb::LoopApply, Scope::Apply),
93        (Verb::Admin, Scope::Admin),
94    ] {
95        // Either the whole queue, or coverage of every namespace the
96        // finding was derived from. Never a partial read: a finding derived
97        // from `a` and `b` discloses both, so holding `a` alone is not
98        // enough.
99        if authz.allows(verb, LOOP_NS) || scope.iter().all(|ns| authz.allows(verb, ns)) {
100            scopes.push(s);
101        }
102    }
103    areev_loop::ScopeSet::of(&scopes)
104}
105
106/// Whether this session may SEE a recommendation with this scope (#312).
107///
108/// A recommendation the caller does not cover answers "not found" rather
109/// than "not authorized", so its existence is not disclosed — the same
110/// reasoning `recall` applies when it declines to name a sibling namespace
111/// in a refusal.
112pub fn covers_rec(authz: &areev_core::authz::AuthzSet, scope: &[String]) -> bool {
113    scopes_for_rec(authz, scope).has(areev_loop::Scope::Read)
114}
115
116/// Every recommendation a session may SEE, filtered by coverage (#312).
117///
118/// ONE filtered read that every surface goes through — the CLI, the server,
119/// MCP, both bindings and `DESCRIBE LOOP` — so a surface added later cannot
120/// forget the check by calling `Engine::recommendations` directly and
121/// listing the whole namespace.
122pub fn visible_recommendations<S: areev_loop::OmsSubstrate>(
123    engine: &areev_loop::Engine,
124    sub: &S,
125    authz: &areev_core::authz::AuthzSet,
126    status: Option<areev_loop::RecStatus>,
127) -> areev_loop::Result<Vec<areev_loop::Recommendation>> {
128    let all = engine.recommendations(sub, status)?;
129    Ok(all.into_iter().filter(|r| covers_rec(authz, &r.scope)).collect())
130}
131
132/// The observer type an actor label implies, used where no credential record
133/// declares one (the credential map will carry an explicit `observer` field
134/// when the multi-token surfaces land; this prefix heuristic is the interim
135/// derivation — a *statement* must never be able to claim humanity, so the
136/// answer always comes from the host-held actor label, not request text).
137pub fn observer_for_principal(actor: &str) -> areev_loop::ObserverType {
138    match areev_core::authz::observer_kind(actor) {
139        "agent" => areev_loop::ObserverType::Agent,
140        _ => areev_loop::ObserverType::Human,
141    }
142}
143
144/// Wall-clock now in epoch milliseconds — the `now_ms` the engine's `run`,
145/// `review`, `apply`, and `rollback` take. Kept out of `areev-loop` itself so the
146/// engine stays deterministic (the caller supplies the clock).
147///
148/// `AREEV_LOOP_NOW_MS` (epoch ms) overrides the wall clock — the simulation seam
149/// that makes a run through the real binary a pure function of (file, policy,
150/// time). The golden E2E suite uses it to pin analyzer output and to step time
151/// across outcome-review horizons and rejection cooldowns without sleeping.
152/// A set-but-unparseable value panics: the caller asked for simulated time,
153/// and silently running at wall time instead would defeat the point.
154pub fn now_ms() -> i64 {
155    if let Ok(v) = std::env::var("AREEV_LOOP_NOW_MS") {
156        return v
157            .trim()
158            .parse()
159            .unwrap_or_else(|_| panic!("AREEV_LOOP_NOW_MS is set but not epoch milliseconds: {v:?}"));
160    }
161    SystemTime::now()
162        .duration_since(UNIX_EPOCH)
163        .map(|d| d.as_millis() as i64)
164        .unwrap_or(0)
165}
166
167#[cfg(test)]
168mod authz_mapping_tests {
169    use super::*;
170    use areev_core::authz::{AuthzSet, Grant, Verb};
171
172    fn granted(principal: &str, verbs: Vec<Verb>, ns: &[&str]) -> AuthzSet {
173        AuthzSet::restricted(
174            principal,
175            vec![Grant {
176                verbs,
177                namespaces: ns.iter().map(|s| (*s).to_string()).collect(),
178            }],
179        )
180    }
181
182    // ---- #312: coverage over a recommendation's own scope ----------------
183
184    #[test]
185    fn a_namespace_grant_covers_a_finding_derived_from_that_namespace() {
186        // The new capability: a reviewer granted on the namespace they work
187        // in can read and decide findings derived from it — without a grant
188        // on `areev-loop`, which would hand them the whole queue.
189        let amy = granted("user:amy", vec![Verb::Read, Verb::LoopReview], &["a"]);
190        let s = scopes_for_rec(&amy, &["a".into()]);
191        assert!(s.has(areev_loop::Scope::Read));
192        assert!(s.has(areev_loop::Scope::Review));
193        assert!(covers_rec(&amy, &["a".into()]));
194    }
195
196    #[test]
197    fn a_namespace_grant_does_not_cover_another_namespaces_finding() {
198        let amy = granted("user:amy", vec![Verb::Read, Verb::LoopReview], &["a"]);
199        assert!(!covers_rec(&amy, &["b".into()]));
200        let s = scopes_for_rec(&amy, &["b".into()]);
201        assert!(!s.has(areev_loop::Scope::Read));
202        assert!(!s.has(areev_loop::Scope::Review));
203    }
204
205    #[test]
206    fn coverage_needs_every_namespace_a_finding_was_derived_from() {
207        // A finding derived from `a` and `b` discloses both, so holding `a`
208        // alone is not enough — it is not a partial read.
209        let amy = granted("user:amy", vec![Verb::Read], &["a"]);
210        assert!(!covers_rec(&amy, &["a".into(), "b".into()]));
211        let both = granted("user:both", vec![Verb::Read], &["a", "b"]);
212        assert!(covers_rec(&both, &["a".into(), "b".into()]));
213    }
214
215    #[test]
216    fn a_whole_queue_grant_still_means_the_whole_queue() {
217        // Owner sessions and existing operator grants are unchanged.
218        let op = granted(
219            "user:op",
220            vec![Verb::Read, Verb::LoopReview],
221            &[areev_loop::LOOP_NS],
222        );
223        assert!(covers_rec(&op, &["a".into()]));
224        assert!(covers_rec(&op, &["a".into(), "b".into()]));
225        assert!(scopes_for_rec(&op, &["zzz".into()]).has(areev_loop::Scope::Review));
226        assert!(covers_rec(&AuthzSet::owner("user:local"), &["a".into()]));
227    }
228
229    #[test]
230    fn an_unscoped_finding_needs_a_whole_queue_grant() {
231        // An unscoped pass — and every recommendation written before the
232        // scope existed — is "derived from we-don't-know-where". Fail closed.
233        let amy = granted("user:amy", vec![Verb::Read, Verb::LoopReview], &["a"]);
234        assert!(!covers_rec(&amy, &[]));
235        let op = granted("user:op", vec![Verb::Read], &[areev_loop::LOOP_NS]);
236        assert!(covers_rec(&op, &[]));
237        assert!(covers_rec(&AuthzSet::owner("user:local"), &[]));
238    }
239
240    #[test]
241    fn coverage_is_per_verb() {
242        // Read but not review: they can see it, and cannot decide it.
243        let amy = granted("user:amy", vec![Verb::Read], &["a"]);
244        let s = scopes_for_rec(&amy, &["a".into()]);
245        assert!(s.has(areev_loop::Scope::Read));
246        assert!(!s.has(areev_loop::Scope::Review));
247        assert!(!s.has(areev_loop::Scope::Apply));
248    }
249
250    #[test]
251    fn scope_normalization_is_order_and_duplicate_independent() {
252        use areev_loop::normalize_scope;
253        assert_eq!(
254            normalize_scope(&["b".into(), "a".into(), "a".into(), "  ".into()]),
255            vec!["a".to_string(), "b".to_string()]
256        );
257    }
258
259    #[test]
260    fn owner_maps_to_every_scope() {
261        let scopes = scopes_for(&AuthzSet::owner("user:local"));
262        for s in [
263            areev_loop::Scope::Read,
264            areev_loop::Scope::Write,
265            areev_loop::Scope::Review,
266            areev_loop::Scope::Apply,
267            areev_loop::Scope::Admin,
268        ] {
269            assert!(scopes.has(s), "{s:?} missing for owner");
270        }
271    }
272
273    #[test]
274    fn loop_verbs_map_one_to_one_and_namespace_scoping_holds() {
275        // A reviewer granted on `*` gets exactly Review (plus nothing else).
276        let reviewer = AuthzSet::restricted(
277            "user:rev",
278            vec![Grant { verbs: vec![Verb::LoopReview], namespaces: vec!["*".into()] }],
279        );
280        let scopes = scopes_for(&reviewer);
281        assert!(scopes.has(areev_loop::Scope::Review));
282        for s in [
283            areev_loop::Scope::Read,
284            areev_loop::Scope::Write,
285            areev_loop::Scope::Apply,
286            areev_loop::Scope::Admin,
287        ] {
288            assert!(!scopes.has(s), "{s:?} must not be granted");
289        }
290
291        // Loop verbs are checked against the loop's own namespace — a grant
292        // scoped to some data namespace does not reach the review queue.
293        let elsewhere = AuthzSet::restricted(
294            "user:misscoped",
295            vec![Grant { verbs: vec![Verb::LoopReview], namespaces: vec!["caller".into()] }],
296        );
297        assert!(!scopes_for(&elsewhere).has(areev_loop::Scope::Review));
298        let on_loop_ns = AuthzSet::restricted(
299            "user:scoped",
300            vec![Grant {
301                verbs: vec![Verb::LoopReview],
302                namespaces: vec![areev_loop::LOOP_NS.into()],
303            }],
304        );
305        assert!(scopes_for(&on_loop_ns).has(areev_loop::Scope::Review));
306    }
307
308    #[test]
309    fn observer_derives_from_the_actor_label() {
310        use areev_loop::ObserverType;
311        assert_eq!(observer_for_principal("agent:mcp"), ObserverType::Agent);
312        assert_eq!(observer_for_principal("bot:sweeper"), ObserverType::Agent);
313        assert_eq!(observer_for_principal("job:retention"), ObserverType::Agent);
314        assert_eq!(observer_for_principal("engine:loop.llm/1"), ObserverType::Agent);
315        assert_eq!(observer_for_principal("user:anna"), ObserverType::Human);
316        assert_eq!(observer_for_principal("anna"), ObserverType::Human);
317    }
318}