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 decide;
20mod governance;
21mod substrate;
22
23pub use decide::LoopDecider;
24pub use governance::LoopGovernance;
25pub use substrate::{loop_state_of, AreevSubstrate, BorrowedSubstrate};
26
27use std::time::{SystemTime, UNIX_EPOCH};
28
29/// Map a session's grants onto the loop engine's scope set — the one
30/// translation between Areev's verb model (`areev_core::authz`) and
31/// `areev_loop::Scope`, so surfaces stop handing out `ScopeSet::all()`
32/// unconditionally. Loop verbs are checked against the loop's own namespace
33/// (`areev_loop::LOOP_NS`), which a grant covers by naming it or `*`.
34/// An owner session maps to every scope — the CLI's local-root-of-trust
35/// behavior, unchanged.
36pub fn scopes_for(authz: &areev_core::authz::AuthzSet) -> areev_loop::ScopeSet {
37    use areev_loop::Scope;
38    use areev_loop::LOOP_NS;
39    use areev_core::authz::Verb;
40    let mut scopes = Vec::new();
41    for (verb, scope) in [
42        (Verb::Read, Scope::Read),
43        (Verb::Write, Scope::Write),
44        (Verb::LoopReview, Scope::Review),
45        (Verb::LoopApply, Scope::Apply),
46        (Verb::Admin, Scope::Admin),
47    ] {
48        if authz.allows(verb, LOOP_NS) {
49            scopes.push(scope);
50        }
51    }
52    areev_loop::ScopeSet::of(&scopes)
53}
54
55/// The scopes a session holds over ONE recommendation, given the namespaces
56/// it was derived from (#312).
57///
58/// The loop's reads are namespace-grant-gated; its outputs were not — every
59/// recommendation went to one namespace, `areev-loop`, and rights were
60/// checked against that one namespace. So one `read ON areev-loop` grant
61/// disclosed the summary, proposal, guidance and evidence hashes of findings
62/// derived from every namespace in the memory, and one `loop.review` grant
63/// decided all of them, striking a memory-wide cooldown each time.
64///
65/// The rule: a principal covers a recommendation when its grants allow the
66/// verb on EVERY namespace in the recommendation's scope.
67///
68/// Two deliberate escape hatches, both of which keep existing deployments
69/// working unchanged:
70///
71/// * A grant on `areev-loop` itself (or `*`) means the WHOLE queue, so owner
72///   sessions and today's operator grants behave exactly as before.
73/// * An EMPTY scope — an unscoped pass, and every recommendation written
74///   before this existed — is covered only by such a whole-queue grant. Fail
75///   closed: "derived from we-don't-know-where" must not be readable by
76///   someone holding one namespace.
77pub fn scopes_for_rec(
78    authz: &areev_core::authz::AuthzSet,
79    scope: &[String],
80) -> areev_loop::ScopeSet {
81    use areev_core::authz::Verb;
82    use areev_loop::Scope;
83    use areev_loop::LOOP_NS;
84    // The whole-queue grant short-circuits: unchanged behaviour.
85    let whole_queue = scopes_for(authz);
86    if scope.is_empty() {
87        return whole_queue;
88    }
89    let mut scopes = Vec::new();
90    for (verb, s) in [
91        (Verb::Read, Scope::Read),
92        (Verb::Write, Scope::Write),
93        (Verb::LoopReview, Scope::Review),
94        (Verb::LoopApply, Scope::Apply),
95        (Verb::Admin, Scope::Admin),
96    ] {
97        // Either the whole queue, or coverage of every namespace the
98        // finding was derived from. Never a partial read: a finding derived
99        // from `a` and `b` discloses both, so holding `a` alone is not
100        // enough.
101        if authz.allows(verb, LOOP_NS) || scope.iter().all(|ns| authz.allows(verb, ns)) {
102            scopes.push(s);
103        }
104    }
105    areev_loop::ScopeSet::of(&scopes)
106}
107
108/// Whether this session may SEE a recommendation with this scope (#312).
109///
110/// A recommendation the caller does not cover answers "not found" rather
111/// than "not authorized", so its existence is not disclosed — the same
112/// reasoning `recall` applies when it declines to name a sibling namespace
113/// in a refusal.
114pub fn covers_rec(authz: &areev_core::authz::AuthzSet, scope: &[String]) -> bool {
115    scopes_for_rec(authz, scope).has(areev_loop::Scope::Read)
116}
117
118/// Every recommendation a session may SEE, filtered by coverage (#312).
119///
120/// ONE filtered read that every surface goes through — the CLI, the server,
121/// MCP, both bindings and `DESCRIBE LOOP` — so a surface added later cannot
122/// forget the check by calling `Engine::recommendations` directly and
123/// listing the whole namespace.
124pub fn visible_recommendations<S: areev_loop::OmsSubstrate>(
125    engine: &areev_loop::Engine,
126    sub: &S,
127    authz: &areev_core::authz::AuthzSet,
128    status: Option<areev_loop::RecStatus>,
129) -> areev_loop::Result<Vec<areev_loop::Recommendation>> {
130    let all = engine.recommendations(sub, status)?;
131    Ok(all.into_iter().filter(|r| covers_rec(authz, &r.scope)).collect())
132}
133
134/// The one recommendation among `recs` whose hash starts with `prefix` —
135/// the lookup `areev loop show` and the bindings' `recommendation(hash)`
136/// share. Pass the coverage-filtered list ([`visible_recommendations`]) on a
137/// principal-bindable surface, so a hash the caller cannot see is "no
138/// match", indistinguishable from one that does not exist.
139pub fn find_recommendation<'a>(
140    recs: &'a [areev_loop::Recommendation],
141    prefix: &str,
142) -> Result<&'a areev_loop::Recommendation, String> {
143    let matches: Vec<&areev_loop::Recommendation> =
144        recs.iter().filter(|r| r.hash.starts_with(prefix)).collect();
145    match matches.as_slice() {
146        [] => Err(format!("no recommendation matches '{prefix}'")),
147        [one] => Ok(one),
148        many => Err(format!(
149            "'{prefix}' is ambiguous ({} matches) — use more characters",
150            many.len()
151        )),
152    }
153}
154
155/// One recommendation as the review surface shows it: `areev loop show` and
156/// the bindings' `recommendation(hash)` print exactly this object (#348).
157///
158/// The reviewable change itself rides along — `show` is the review surface,
159/// so the proposal must be visible before approve/apply. Flattened
160/// (`proposal` kind tag + its fields, e.g. `cal`) exactly like the stored
161/// grain body; `action_kind`, the outcome metric and any LLM guidance ride
162/// along too. The proposal is derived content governed by the same grants
163/// as the summary: callers reach this only through a coverage-filtered list.
164pub fn recommendation_detail(r: &areev_loop::Recommendation) -> serde_json::Value {
165    let mut out = serde_json::json!({
166        "hash": r.hash,
167        "status": r.status.as_str(),
168        "severity": r.severity.as_str(),
169        "analyzer": r.analyzer,
170        "origin": r.origin,
171        "target_ref": r.target_ref,
172        "action_kind": r.action_kind,
173        "summary": r.summary.render(),
174        "destructive": r.destructive,
175        "rollbackable": r.rollbackable,
176        "evidence": r.evidence,
177        "dedup_key": r.dedup_key,
178        "confidence": r.confidence,
179    });
180    let o = out.as_object_mut().expect("json! object");
181    if let Some(pin) = &r.evalset_hash {
182        o.insert("evalset_hash".into(), serde_json::json!(pin));
183    }
184    if !r.near_duplicate_of.is_empty() {
185        o.insert("near_duplicate_of".into(), serde_json::json!(r.near_duplicate_of));
186    }
187    // A plan revision's rehearsal against the plan's journaled runs
188    // (`areev run shadow --plan-file`), when the substrate could run
189    // one — the reviewer approves from evidence, not prose.
190    if let Some(replay) = &r.replay {
191        o.insert("replay".into(), replay.clone());
192    }
193    o.extend(proposal_fields(r));
194    if let Some(m) = r.metric.as_ref().and_then(|m| serde_json::to_value(m).ok()) {
195        o.insert("metric".into(), m);
196    }
197    if let Some(gd) = &r.guidance {
198        o.insert("guidance".into(), serde_json::Value::from(gd.clone()));
199    }
200    // A decision backend's part in this finding (provider, model,
201    // calibrated, latency, the probabilities) and, for an LLM draft routed
202    // on the decision, the verifier's own self-report beside it — the
203    // reviewer sees who judged, and any disagreement.
204    if let Some(j) = &r.judged_by {
205        o.insert("judged_by".into(), serde_json::json!(j));
206    }
207    if let Some(c) = r.llm_confidence {
208        o.insert("llm_confidence".into(), serde_json::json!(c));
209    }
210    out
211}
212
213/// The flattened proposal body (`proposal` kind tag + its fields) — what
214/// `recommendations({"include": "proposal"})` adds to a listing row.
215pub fn proposal_fields(r: &areev_loop::Recommendation) -> serde_json::Map<String, serde_json::Value> {
216    match serde_json::to_value(&r.proposal) {
217        Ok(serde_json::Value::Object(p)) => p,
218        _ => serde_json::Map::new(),
219    }
220}
221
222/// The observer type an actor label implies, used where no credential record
223/// declares one (the credential map will carry an explicit `observer` field
224/// when the multi-token surfaces land; this prefix heuristic is the interim
225/// derivation — a *statement* must never be able to claim humanity, so the
226/// answer always comes from the host-held actor label, not request text).
227pub fn observer_for_principal(actor: &str) -> areev_loop::ObserverType {
228    match areev_core::authz::observer_kind(actor) {
229        "agent" => areev_loop::ObserverType::Agent,
230        _ => areev_loop::ObserverType::Human,
231    }
232}
233
234/// Wall-clock now in epoch milliseconds — the `now_ms` the engine's `run`,
235/// `review`, `apply`, and `rollback` take. Kept out of `areev-loop` itself so the
236/// engine stays deterministic (the caller supplies the clock).
237///
238/// `AREEV_LOOP_NOW_MS` (epoch ms) overrides the wall clock — the simulation seam
239/// that makes a run through the real binary a pure function of (file, policy,
240/// time). The golden E2E suite uses it to pin analyzer output and to step time
241/// across outcome-review horizons and rejection cooldowns without sleeping.
242/// A set-but-unparseable value panics: the caller asked for simulated time,
243/// and silently running at wall time instead would defeat the point.
244pub fn now_ms() -> i64 {
245    if let Ok(v) = std::env::var("AREEV_LOOP_NOW_MS") {
246        return v
247            .trim()
248            .parse()
249            .unwrap_or_else(|_| panic!("AREEV_LOOP_NOW_MS is set but not epoch milliseconds: {v:?}"));
250    }
251    SystemTime::now()
252        .duration_since(UNIX_EPOCH)
253        .map(|d| d.as_millis() as i64)
254        .unwrap_or(0)
255}
256
257#[cfg(test)]
258mod authz_mapping_tests {
259    use super::*;
260    use areev_core::authz::{AuthzSet, Grant, Verb};
261
262    fn granted(principal: &str, verbs: Vec<Verb>, ns: &[&str]) -> AuthzSet {
263        AuthzSet::restricted(
264            principal,
265            vec![Grant {
266                verbs,
267                namespaces: ns.iter().map(|s| (*s).to_string()).collect(),
268            }],
269        )
270    }
271
272    // ---- #312: coverage over a recommendation's own scope ----------------
273
274    #[test]
275    fn a_namespace_grant_covers_a_finding_derived_from_that_namespace() {
276        // The new capability: a reviewer granted on the namespace they work
277        // in can read and decide findings derived from it — without a grant
278        // on `areev-loop`, which would hand them the whole queue.
279        let amy = granted("user:amy", vec![Verb::Read, Verb::LoopReview], &["a"]);
280        let s = scopes_for_rec(&amy, &["a".into()]);
281        assert!(s.has(areev_loop::Scope::Read));
282        assert!(s.has(areev_loop::Scope::Review));
283        assert!(covers_rec(&amy, &["a".into()]));
284    }
285
286    #[test]
287    fn a_namespace_grant_does_not_cover_another_namespaces_finding() {
288        let amy = granted("user:amy", vec![Verb::Read, Verb::LoopReview], &["a"]);
289        assert!(!covers_rec(&amy, &["b".into()]));
290        let s = scopes_for_rec(&amy, &["b".into()]);
291        assert!(!s.has(areev_loop::Scope::Read));
292        assert!(!s.has(areev_loop::Scope::Review));
293    }
294
295    #[test]
296    fn coverage_needs_every_namespace_a_finding_was_derived_from() {
297        // A finding derived from `a` and `b` discloses both, so holding `a`
298        // alone is not enough — it is not a partial read.
299        let amy = granted("user:amy", vec![Verb::Read], &["a"]);
300        assert!(!covers_rec(&amy, &["a".into(), "b".into()]));
301        let both = granted("user:both", vec![Verb::Read], &["a", "b"]);
302        assert!(covers_rec(&both, &["a".into(), "b".into()]));
303    }
304
305    #[test]
306    fn a_whole_queue_grant_still_means_the_whole_queue() {
307        // Owner sessions and existing operator grants are unchanged.
308        let op = granted(
309            "user:op",
310            vec![Verb::Read, Verb::LoopReview],
311            &[areev_loop::LOOP_NS],
312        );
313        assert!(covers_rec(&op, &["a".into()]));
314        assert!(covers_rec(&op, &["a".into(), "b".into()]));
315        assert!(scopes_for_rec(&op, &["zzz".into()]).has(areev_loop::Scope::Review));
316        assert!(covers_rec(&AuthzSet::owner("user:local"), &["a".into()]));
317    }
318
319    #[test]
320    fn an_unscoped_finding_needs_a_whole_queue_grant() {
321        // An unscoped pass — and every recommendation written before the
322        // scope existed — is "derived from we-don't-know-where". Fail closed.
323        let amy = granted("user:amy", vec![Verb::Read, Verb::LoopReview], &["a"]);
324        assert!(!covers_rec(&amy, &[]));
325        let op = granted("user:op", vec![Verb::Read], &[areev_loop::LOOP_NS]);
326        assert!(covers_rec(&op, &[]));
327        assert!(covers_rec(&AuthzSet::owner("user:local"), &[]));
328    }
329
330    #[test]
331    fn coverage_is_per_verb() {
332        // Read but not review: they can see it, and cannot decide it.
333        let amy = granted("user:amy", vec![Verb::Read], &["a"]);
334        let s = scopes_for_rec(&amy, &["a".into()]);
335        assert!(s.has(areev_loop::Scope::Read));
336        assert!(!s.has(areev_loop::Scope::Review));
337        assert!(!s.has(areev_loop::Scope::Apply));
338    }
339
340    #[test]
341    fn scope_normalization_is_order_and_duplicate_independent() {
342        use areev_loop::normalize_scope;
343        assert_eq!(
344            normalize_scope(&["b".into(), "a".into(), "a".into(), "  ".into()]),
345            vec!["a".to_string(), "b".to_string()]
346        );
347    }
348
349    #[test]
350    fn owner_maps_to_every_scope() {
351        let scopes = scopes_for(&AuthzSet::owner("user:local"));
352        for s in [
353            areev_loop::Scope::Read,
354            areev_loop::Scope::Write,
355            areev_loop::Scope::Review,
356            areev_loop::Scope::Apply,
357            areev_loop::Scope::Admin,
358        ] {
359            assert!(scopes.has(s), "{s:?} missing for owner");
360        }
361    }
362
363    #[test]
364    fn loop_verbs_map_one_to_one_and_namespace_scoping_holds() {
365        // A reviewer granted on `*` gets exactly Review (plus nothing else).
366        let reviewer = AuthzSet::restricted(
367            "user:rev",
368            vec![Grant { verbs: vec![Verb::LoopReview], namespaces: vec!["*".into()] }],
369        );
370        let scopes = scopes_for(&reviewer);
371        assert!(scopes.has(areev_loop::Scope::Review));
372        for s in [
373            areev_loop::Scope::Read,
374            areev_loop::Scope::Write,
375            areev_loop::Scope::Apply,
376            areev_loop::Scope::Admin,
377        ] {
378            assert!(!scopes.has(s), "{s:?} must not be granted");
379        }
380
381        // Loop verbs are checked against the loop's own namespace — a grant
382        // scoped to some data namespace does not reach the review queue.
383        let elsewhere = AuthzSet::restricted(
384            "user:misscoped",
385            vec![Grant { verbs: vec![Verb::LoopReview], namespaces: vec!["caller".into()] }],
386        );
387        assert!(!scopes_for(&elsewhere).has(areev_loop::Scope::Review));
388        let on_loop_ns = AuthzSet::restricted(
389            "user:scoped",
390            vec![Grant {
391                verbs: vec![Verb::LoopReview],
392                namespaces: vec![areev_loop::LOOP_NS.into()],
393            }],
394        );
395        assert!(scopes_for(&on_loop_ns).has(areev_loop::Scope::Review));
396    }
397
398    #[test]
399    fn observer_derives_from_the_actor_label() {
400        use areev_loop::ObserverType;
401        assert_eq!(observer_for_principal("agent:mcp"), ObserverType::Agent);
402        assert_eq!(observer_for_principal("bot:sweeper"), ObserverType::Agent);
403        assert_eq!(observer_for_principal("job:retention"), ObserverType::Agent);
404        assert_eq!(observer_for_principal("engine:loop.llm/1"), ObserverType::Agent);
405        assert_eq!(observer_for_principal("user:anna"), ObserverType::Human);
406        assert_eq!(observer_for_principal("anna"), ObserverType::Human);
407    }
408}