Skip to main content

areev_loop_adapter/
substrate.rs

1//! `AreevSubstrate` — implements `areev_loop::OmsSubstrate` over `AreevFacade`.
2//!
3//! Two wrapper types share one set of operations (free functions over
4//! `&AreevFacade`): [`AreevSubstrate`] **owns** its facade (the CLI path,
5//! which moves a store in), while [`BorrowedSubstrate`] holds `&AreevFacade`
6//! (the bindings path, where the pyclass/napi object already owns the facade
7//! and a second store handle would violate single-writer-per-file). Every
8//! operation goes through the facade's `&self` methods (`with_store`,
9//! `cal_add`/`cal_supersede`/`cal_delete`), so a shared borrow suffices even
10//! for the trait's `&mut self` writers.
11//!
12//! Two mapping decisions, both interim and documented:
13//!
14//! 1. **Areev Loop-internal grains ride as Facts.** The recommendation grain
15//!    (OMS 0x0C) is not yet realized in areev-core, so recommendation and
16//!    audit grains are stored as Facts in the `areev-loop` namespace with the full
17//!    loop field-map serialized into the Fact's `object`, tagged by
18//!    `relation` (`loop_recommendation` / `loop_audit`). They are real,
19//!    content-addressed, syncable grains; only the type byte differs.
20//! 2. **Liveness comes from `derived_from`.** `supersede` stamps the new
21//!    grain's `derived_from` with the old hash, so a grain is superseded iff
22//!    its hash appears in some sibling's `derived_from`. All-namespace scans
23//!    (no ns filter) enumerate via the op-log (`changes_since`), since `recent`
24//!    requires a namespace.
25
26use areev_cal::classify::{classify, StatementClass};
27use areev_cal::{parse, CalExecutor, CalExecutorConfig, CalStoreFacade, AreevFacade};
28use areev_core::error::{AreevError, Hash};
29use areev_core::format::deserialize::DeserializedGrain;
30use areev_core::authz::Verb;
31use areev_core::types::{Fact, Grain, GrainType};
32use areev_store::{Areev, TelemetryMode, OP_FORGET};
33use serde_json::{Map, Value};
34use std::collections::BTreeSet;
35use areev_loop::error::{Error as WErr, Result as WResult};
36use areev_loop::{
37    BudgetUsage, Capabilities, GrainAccess, GrainRecord, GrainSpec, HeadGroup, OmsSubstrate,
38    QueryUsage, ReadOpts, SubstrateRead, TelemetryView,
39};
40
41/// The namespace the loop's own grains live in (mirrors `areev_loop::LOOP_NS`).
42const LOOP_NS: &str = "areev-loop";
43/// Sentinel subject/relation for the persisted loop state blob.
44const STATE_SUBJECT: &str = "__loop_state__";
45const STATE_RELATION: &str = "state";
46/// Upper bound on grains scanned per query (interim; real substrate paginates).
47const MAX_SCAN: usize = 1_000_000;
48
49fn we(e: AreevError) -> WErr {
50    WErr::Substrate(e.to_string())
51}
52
53fn require_read(f: &AreevFacade, ns: &str) -> WResult<()> {
54    f.authz().check(Verb::Read, ns).map_err(we)
55}
56
57fn may_read(f: &AreevFacade, ns: &str) -> bool {
58    f.authz().allows(Verb::Read, ns)
59}
60
61/// An Areev-backed substrate that owns its facade (CLI path).
62pub struct AreevSubstrate {
63    facade: AreevFacade,
64}
65
66impl AreevSubstrate {
67    /// Wrap an open store, scoping the loop's own writes to `session_ns`
68    /// (defaults to the `areev-loop` namespace when `None`).
69    pub fn new(store: Areev, session_ns: Option<String>) -> Self {
70        let ns = session_ns.unwrap_or_else(|| LOOP_NS.to_string());
71        AreevSubstrate {
72            facade: AreevFacade::with_session(store, Some(ns), None),
73        }
74    }
75
76    /// Recover the underlying store (e.g. to reuse it for another verb).
77    pub fn into_store(self) -> Areev {
78        self.facade.into_inner()
79    }
80}
81
82/// An Areev-backed substrate that borrows a facade (bindings path). Construct
83/// one per areev-loop call over the host object's existing `&AreevFacade`.
84pub struct BorrowedSubstrate<'a> {
85    facade: &'a AreevFacade,
86}
87
88impl<'a> BorrowedSubstrate<'a> {
89    pub fn new(facade: &'a AreevFacade) -> Self {
90        BorrowedSubstrate { facade }
91    }
92}
93
94impl AreevSubstrate {
95    fn facade_ref(&self) -> &AreevFacade {
96        &self.facade
97    }
98
99    /// Store-level joins the loop vocabulary doesn't cover (the CLI's §7.4
100    /// blast-radius/provenance reports need `runs_touching`). Read-only use.
101    pub fn facade(&self) -> &AreevFacade {
102        &self.facade
103    }
104}
105
106impl BorrowedSubstrate<'_> {
107    fn facade_ref(&self) -> &AreevFacade {
108        self.facade
109    }
110}
111
112// The two wrappers implement the traits identically by delegating to the free
113// functions below (each operation borrows the facade only for its own call).
114macro_rules! impl_substrate {
115    ($ty:ty) => {
116        impl SubstrateRead for $ty {
117            fn capabilities(&self) -> Capabilities {
118                caps(self.facade_ref())
119            }
120            fn grains_of_type(
121                &self,
122                grain_type: &str,
123                namespace: Option<&str>,
124                opts: ReadOpts,
125            ) -> WResult<Vec<GrainRecord>> {
126                grains_of_type(self.facade_ref(), grain_type, namespace, opts)
127            }
128            fn grain(&self, hash: &str) -> WResult<Option<GrainRecord>> {
129                grain(self.facade_ref(), hash)
130            }
131            fn heads(&self, namespace: Option<&str>) -> WResult<Vec<HeadGroup>> {
132                heads(self.facade_ref(), namespace)
133            }
134            fn telemetry(&self, namespace: Option<&str>) -> WResult<Option<TelemetryView>> {
135                telemetry(self.facade_ref(), namespace)
136            }
137        }
138
139        impl OmsSubstrate for $ty {
140            fn put_grain(&mut self, spec: &GrainSpec) -> WResult<String> {
141                put_grain(self.facade_ref(), spec)
142            }
143            fn supersede(
144                &mut self,
145                target_hash: &str,
146                spec: &GrainSpec,
147                _j: &str,
148            ) -> WResult<String> {
149                supersede_op(self.facade_ref(), target_hash, spec)
150            }
151            fn retract(&mut self, hash: &str, _reason: &str) -> WResult<()> {
152                retract_op(self.facade_ref(), hash)
153            }
154            fn execute_cal(&mut self, cal: &str) -> WResult<Vec<Value>> {
155                execute_cal(self.facade_ref(), cal)
156            }
157            fn validate_cal(&self, cal: &str) -> WResult<()> {
158                validate_cal(cal)
159            }
160            fn load_state(&self) -> WResult<Value> {
161                load_state(self.facade_ref())
162            }
163            fn store_state(&mut self, state: &Value) -> WResult<()> {
164                store_state(self.facade_ref(), state)
165            }
166            fn put_blob(&mut self, bytes: &[u8]) -> WResult<String> {
167                put_blob_op(self.facade_ref(), bytes)
168            }
169            fn get_blob(&mut self, address: &str) -> WResult<Vec<u8>> {
170                get_blob_op(self.facade_ref(), address)
171            }
172        }
173    };
174}
175
176impl_substrate!(AreevSubstrate);
177impl_substrate!(BorrowedSubstrate<'_>);
178
179// --- operations (free functions over &AreevFacade) ---
180
181fn recent_ns(f: &AreevFacade, ns: &str, gt: GrainType) -> WResult<Vec<DeserializedGrain>> {
182    // An explicitly requested namespace is a capability assertion. Deny it
183    // loudly; silently returning an empty set would misreport authorization
184    // failure as "the analyzer found no data".
185    require_read(f, ns)?;
186    f.with_store(|m| m.recent(ns, Some(gt), MAX_SCAN))
187        .map_err(we)
188}
189
190/// Enumerate every grain of `gt` across all namespaces via the op-log.
191fn all_grains(f: &AreevFacade, gt: GrainType) -> WResult<Vec<DeserializedGrain>> {
192    let ops = f.with_store(|m| m.changes_since(0, MAX_SCAN)).map_err(we)?;
193    let mut seen = BTreeSet::new();
194    let mut out = Vec::new();
195    for op in ops {
196        if op.op == OP_FORGET || !seen.insert(op.hash.to_hex()) {
197            continue;
198        }
199        if let Ok(g) = f.with_store(|m| m.get(&op.hash)) {
200            if g.grain_type == gt && may_read(f, &grain_namespace(&g)) {
201                out.push(g);
202            }
203        }
204    }
205    Ok(out)
206}
207
208fn read_user_type(
209    f: &AreevFacade,
210    gt: GrainType,
211    namespace: Option<&str>,
212    opts: ReadOpts,
213) -> WResult<Vec<GrainRecord>> {
214    let explicit = namespace.is_some();
215    let raw = match namespace {
216        Some(ns) => recent_ns(f, ns, gt)?,
217        None => all_grains(f, gt)?,
218    };
219    let superseded = superseded_set(&raw);
220    let mut out = Vec::new();
221    for g in raw {
222        let ns = grain_namespace(&g);
223        // Never surface loop-internal or governance grains in ALL-NAMESPACE
224        // scans. `agent:authz` holds the file's own grant Facts and the
225        // Tier-2 audit Observations; an analyzer sweeping everything that
226        // sees them as ordinary memory can propose tombstoning them
227        // (retention_sweep did), which locks every non-owner principal out
228        // of the file and destroys the very Art. 30 records the audit
229        // export exists to produce. Governance state is metadata about the
230        // memory, not memory. An EXPLICITLY requested namespace is
231        // different: the caller named it, `recent_ns` authz-checked it, and
232        // consumers like the run_outcome analyzer (agent:harness) and the
233        // gating-evidence loader depend on reading it.
234        if !explicit && (ns == LOOP_NS || ns.starts_with("agent:")) {
235            continue;
236        }
237        if opts.live_only && superseded.contains(&g.hash.to_hex()) {
238            continue;
239        }
240        let created = grain_created_ms(&g);
241        if opts.since_ms.is_some_and(|s| created < s) {
242            continue;
243        }
244        out.push(map_user_grain(&g, ns, created));
245    }
246    Ok(out)
247}
248
249/// Read loop-internal grains (recommendations / audit) stored as Facts.
250fn read_loop(
251    f: &AreevFacade,
252    relation: &str,
253    out_type: &str,
254    opts: ReadOpts,
255) -> WResult<Vec<GrainRecord>> {
256    let facts = recent_ns(f, LOOP_NS, GrainType::Fact)?;
257    let mut out = Vec::new();
258    for fact in facts {
259        if fact.get_str("relation") != Some(relation) {
260            continue;
261        }
262        let Some(payload) = fact.get_str("object") else {
263            continue;
264        };
265        let fields: Map<String, Value> = match serde_json::from_str(payload) {
266            Ok(Value::Object(m)) => m,
267            _ => continue,
268        };
269        let created = grain_created_ms(&fact);
270        if opts.since_ms.is_some_and(|s| created < s) {
271            continue;
272        }
273        out.push(loop_record(
274            &fact.hash.to_hex(),
275            out_type,
276            created,
277            fields,
278        ));
279    }
280    Ok(out)
281}
282
283fn caps(f: &AreevFacade) -> Capabilities {
284    Capabilities {
285        forks: true,
286        // On iff the host attached a telemetry sidecar (`aggregate`/`full`).
287        telemetry: f.with_store(|m| m.telemetry_mode()) != TelemetryMode::Off,
288        embeddings: f.with_store(|m| m.declared_embedding().is_some()),
289    }
290}
291
292/// Read the recall-telemetry sidecar rollups into the loop's substrate-agnostic
293/// [`TelemetryView`]. `None` when no sidecar is attached, so telemetry-fed
294/// analyzers degrade cleanly.
295fn telemetry(f: &AreevFacade, namespace: Option<&str>) -> WResult<Option<TelemetryView>> {
296    if f.with_store(|m| m.telemetry_mode()) == TelemetryMode::Off {
297        return Ok(None);
298    }
299    if let Some(ns) = namespace {
300        require_read(f, ns)?;
301    }
302    let access = f.with_store(|m| m.telemetry_access_stats(namespace)).map_err(we)?;
303    let queries = f.with_store(|m| m.telemetry_query_stats(namespace)).map_err(we)?;
304    // Budget telemetry has no namespace column. It is only safe to expose to
305    // a session that may read the whole memory; namespace-scoped sessions
306    // still receive their filtered access/query rollups below.
307    let budget = if may_read(f, "*") {
308        f.with_store(|m| m.telemetry_budget_stats()).map_err(we)?
309    } else {
310        Default::default()
311    };
312    Ok(Some(TelemetryView {
313        access: access
314            .into_iter()
315            .filter(|a| may_read(f, &a.ns))
316            .map(|a| GrainAccess { hash: a.hash, recall_count: a.recall_count, last_ms: a.last_ms })
317            .collect(),
318        queries: queries
319            .into_iter()
320            .filter(|q| may_read(f, &q.ns))
321            .map(|q| QueryUsage {
322                sample: q.sample,
323                run_count: q.run_count,
324                empty_count: q.empty_count,
325                sum_results: q.sum_results,
326                last_ms: q.last_ms,
327            })
328            .collect(),
329        budget: BudgetUsage {
330            sample_count: budget.sample_count,
331            overflow_count: budget.overflow_count,
332        },
333    }))
334}
335
336fn grains_of_type(
337    f: &AreevFacade,
338    grain_type: &str,
339    namespace: Option<&str>,
340    opts: ReadOpts,
341) -> WResult<Vec<GrainRecord>> {
342    match grain_type {
343        "recommendation" => read_loop(f, "loop_recommendation", "recommendation", opts),
344        "fact" => read_user_type(f, GrainType::Fact, namespace, opts),
345        "event" => read_user_type(f, GrainType::Event, namespace, opts),
346        "tool" => read_user_type(f, GrainType::Tool, namespace, opts),
347        // Loop audit records ride as Facts (mapping decision 1); asking for
348        // observations IN the loop namespace means those — the §7.4
349        // forensics chain (approver, BECAUSE, gating fields) reads here.
350        "observation" if namespace == Some(LOOP_NS) => {
351            read_loop(f, "loop_audit", "observation", opts)
352        }
353        "observation" => read_user_type(f, GrainType::Observation, namespace, opts),
354        "skill" => read_user_type(f, GrainType::Skill, namespace, opts),
355        "goal" => read_user_type(f, GrainType::Goal, namespace, opts),
356        other => Err(WErr::Substrate(format!("unsupported grain type {other:?}"))),
357    }
358}
359
360fn grain(f: &AreevFacade, hash: &str) -> WResult<Option<GrainRecord>> {
361    let h = Hash::from_hex(hash).map_err(we)?;
362    let g = match f.with_store(|m| m.get(&h)) {
363        Ok(g) => g,
364        Err(AreevError::NotFound(_)) => return Ok(None),
365        Err(e) => return Err(we(e)),
366    };
367    let created = grain_created_ms(&g);
368    // Supersession from the STORE'S index — `is_live()` decisions (the
369    // apply-gate's evalset-liveness check among them) must see the truth,
370    // not a hard-coded None. The review that found this: a superseded
371    // evalset read back as live would re-admit code its weakened
372    // replacement was supposed to force through re-gating.
373    let superseded_by = f
374        .with_store(|m| m.supersession_map(&[h]))
375        .map_err(we)?
376        .get(&h)
377        .map(|s| s.to_hex());
378    let ns = grain_namespace(&g);
379    // Reconstruct loop-internal grains from their JSON payload — but ONLY
380    // grains actually living in the loop namespace, and only for readers
381    // the loop namespace admits (a user Fact elsewhere that happens to use
382    // these relation strings is user data behind its own namespace's gate).
383    if ns == LOOP_NS {
384        if let Some(rel) = g.get_str("relation") {
385            if let Some(out_type) = loop_relation_type(rel) {
386                require_read(f, LOOP_NS)?;
387                if let Some(Value::Object(fields)) = g
388                    .get_str("object")
389                    .and_then(|p| serde_json::from_str(p).ok())
390                {
391                    let mut rec = loop_record(hash, out_type, created, fields);
392                    rec.superseded_by = superseded_by;
393                    return Ok(Some(rec));
394                }
395            }
396        }
397    }
398    require_read(f, &ns)?;
399    let mut rec = map_user_grain(&g, ns, created);
400    rec.superseded_by = superseded_by;
401    Ok(Some(rec))
402}
403
404fn heads(f: &AreevFacade, namespace: Option<&str>) -> WResult<Vec<HeadGroup>> {
405    let forks = f.with_store(|m| m.open_forks()).map_err(we)?;
406    Ok(forks
407        .into_iter()
408        .filter(|fg| fg.namespace != LOOP_NS)
409        .filter(|fg| may_read(f, &fg.namespace))
410        .filter(|fg| namespace.is_none_or(|ns| fg.namespace == ns))
411        .map(|fg| HeadGroup {
412            entity: format!("{}/{}", fg.subject, fg.relation),
413            heads: fg.heads.iter().map(|h| h.to_hex()).collect(),
414        })
415        .collect())
416}
417
418/// §7.4 blob seam: candidate code / evalset payloads ride the memory's own
419/// CAS (encrypted under the sidecar key when the memory is; addresses are
420/// plaintext digests — the documented oracle). Write-gated on the loop
421/// namespace: a session that cannot write loop grains cannot carry code.
422/// The cap on carried code, not on stored bytes: candidates above it belong
423/// in authored adds, not loop payloads.
424const MAX_LOOP_BLOB: usize = 4 * 1024 * 1024;
425
426fn put_blob_op(f: &AreevFacade, bytes: &[u8]) -> WResult<String> {
427    f.authz().check(Verb::Write, LOOP_NS).map_err(we)?;
428    if bytes.len() > MAX_LOOP_BLOB {
429        return Err(WErr::Substrate(format!(
430            "blob is {} bytes (loop cap {MAX_LOOP_BLOB})",
431            bytes.len()
432        )));
433    }
434    f.with_store(|m| m.put_blob(bytes)).map_err(we)
435}
436
437fn get_blob_op(f: &AreevFacade, address: &str) -> WResult<Vec<u8>> {
438    require_read(f, LOOP_NS)?;
439    f.with_store(|m| m.get_blob(address)).map_err(we)
440}
441
442fn put_grain(f: &AreevFacade, spec: &GrainSpec) -> WResult<String> {
443    // A fact spec that carries its OWN (subject, relation, object) — the
444    // engine's §7.4 code-promotion record — is stored as that triple, so
445    // hosts can query `(tool:X, mg:code_promotion)` to re-resolve. Wrapping
446    // it under a synthetic subject (the generic path below) would disguise
447    // the promotion as an unqueryable pseudo-audit row.
448    if spec.grain_type == "fact" {
449        if let (Some(subject), Some(relation), Some(object)) = (
450            spec.fields.get("subject").and_then(Value::as_str),
451            spec.fields.get("relation").and_then(Value::as_str),
452            spec.fields.get("object").and_then(Value::as_str),
453        ) {
454            let mut fact = Fact::new(subject, relation, object)
455                .namespace(LOOP_NS)
456                .confidence(1.0);
457            fact.common.created_at = spec_created_ms(spec);
458            for (k, v) in &spec.fields {
459                if !matches!(k.as_str(), "subject" | "relation" | "object") {
460                    fact.common.extra_fields.insert(k.clone(), v.clone());
461                }
462            }
463            return f.with_store(|m| m.add(&fact)).map(|h| h.to_hex()).map_err(we);
464        }
465    }
466    let payload = serde_json::to_string(&spec.fields)
467        .map_err(|e| WErr::Substrate(format!("encode grain: {e}")))?;
468    let mut fact = Fact::new(
469        &unique_subject(&payload),
470        loop_relation(&spec.grain_type),
471        &payload,
472    )
473    .namespace(LOOP_NS)
474    .confidence(1.0);
475    fact.common.created_at = spec_created_ms(spec);
476    f.with_store(|m| m.add(&fact))
477        .map(|h| h.to_hex())
478        .map_err(we)
479}
480
481fn supersede_op(f: &AreevFacade, target_hash: &str, spec: &GrainSpec) -> WResult<String> {
482    let payload = serde_json::to_string(&spec.fields)
483        .map_err(|e| WErr::Substrate(format!("encode grain: {e}")))?;
484    let old = Hash::from_hex(target_hash).map_err(we)?;
485    let mut fact = Fact::new(
486        &unique_subject(&payload),
487        loop_relation(&spec.grain_type),
488        &payload,
489    )
490    .namespace(LOOP_NS);
491    fact.common.created_at = spec_created_ms(spec);
492    f.with_store(|m| m.supersede(&old, &mut fact))
493        .map(|h| h.to_hex())
494        .map_err(we)
495}
496
497/// The engine-logical creation time already carried in the spec's fields —
498/// `created_at_ms` on recommendations, `at_ms` on audit observations. Stamping
499/// it onto the stored Fact keeps the grain's own `created_at` aligned with the
500/// engine's injected `now` instead of leaking the wall clock, so an areev-loop
501/// write is content-addressed purely from (spec, now). `None` (a spec without
502/// a time field) falls through to the store's own stamp.
503fn spec_created_ms(spec: &GrainSpec) -> Option<i64> {
504    spec.fields
505        .get("created_at_ms")
506        .or_else(|| spec.fields.get("at_ms"))
507        .and_then(Value::as_i64)
508}
509
510fn retract_op(f: &AreevFacade, hash: &str) -> WResult<()> {
511    // No index-only retraction primitive exists; the honest mapping for undoing
512    // an engine-created ADD is a tombstone of that grain.
513    let h = Hash::from_hex(hash).map_err(we)?;
514    f.with_store(|m| m.forget(&h)).map_err(we)
515}
516
517fn execute_cal(f: &AreevFacade, cal: &str) -> WResult<Vec<Value>> {
518    // Areev Loop proposals are a compact store-op form (ADD/SUPERSEDE/FORGET
519    // `<type> {json}`), applied via the facade's typed JSON methods — not the
520    // CAL text grammar (which uses SET/BECAUSE). Genuine CAL reads
521    // (metric/evidence queries) fall through to the real executor.
522    let mut rows = Vec::new();
523    for line in cal.lines() {
524        let line = line.trim();
525        if line.is_empty() {
526            continue;
527        }
528        let (keyword, rest) = split_keyword(line);
529        match keyword.to_ascii_uppercase().as_str() {
530            "FORGET" => {
531                let h = Hash::from_hex(rest.trim()).map_err(we)?;
532                // A loop-applied FORGET carries its reason on the audit
533                // chain already; stamp the Tier-2 audit with the source.
534                f.cal_delete(&h, Some("areev-loop apply")).map_err(we)?;
535            }
536            "ADD" => {
537                let (gtype, fields) = parse_type_and_json(rest)?;
538                let h = f.cal_add(&gtype, &fields).map_err(we)?;
539                rows.push(serde_json::json!({ "hash": h.to_hex() }));
540            }
541            "SUPERSEDE" => {
542                let (target, after) = rest
543                    .split_once(" WITH ")
544                    .ok_or_else(|| WErr::CalUnsupported(format!("malformed SUPERSEDE: {line}")))?;
545                let (gtype, fields) = parse_type_and_json(after)?;
546                let old = Hash::from_hex(target.trim()).map_err(we)?;
547                let h = f.cal_supersede(&old, &gtype, &fields).map_err(we)?;
548                rows.push(serde_json::json!({ "hash": h.to_hex() }));
549            }
550            _ => {
551                // Defense in depth: the engine's destructive gate (admin +
552                // allow_destructive) runs before apply, but this executor is
553                // process-permissive — never let it run destructive text.
554                // Loop-applied destruction goes through the special-cased,
555                // Tier-2-audited arms above (FORGET today), one statement
556                // shape at a time.
557                let parsed = parse(line).map_err(|e| WErr::CalUnsupported(e.to_string()))?;
558                if classify(&parsed.statement) == StatementClass::Destructive {
559                    return Err(WErr::CalUnsupported(format!(
560                        "destructive CAL beyond FORGET <hash> is not executable through the loop substrate: {line}"
561                    )));
562                }
563                let ex = CalExecutor::new(CalExecutorConfig::default());
564                let res = ex
565                    .execute(line, f)
566                    .map_err(|e| WErr::CalUnsupported(e.to_string()))?;
567                let payload = serde_json::to_value(&res.result)
568                    .map_err(|e| WErr::Substrate(format!("encode CAL result: {e}")))?;
569                let mut hs = Vec::new();
570                collect_hashes(&payload, &mut hs);
571                rows.extend(hs.into_iter().map(|h| serde_json::json!({ "hash": h })));
572            }
573        }
574    }
575    Ok(rows)
576}
577
578fn validate_cal(cal: &str) -> WResult<()> {
579    for line in cal.lines() {
580        let line = line.trim();
581        if line.is_empty() {
582            continue;
583        }
584        let (keyword, rest) = split_keyword(line);
585        match keyword.to_ascii_uppercase().as_str() {
586            "ADD" | "SUPERSEDE" => {}
587            // The loop's FORGET arm executes single-grain tombstones only —
588            // `FORGET SUBJECT "<id>"` must fail here, not at apply time.
589            "FORGET" => {
590                Hash::from_hex(rest.trim()).map_err(|_| {
591                    WErr::CalUnsupported(format!(
592                        "loop FORGET takes a single grain hash: {line}"
593                    ))
594                })?;
595            }
596            _ => {
597                let parsed = parse(line).map_err(|e| WErr::CalUnsupported(e.to_string()))?;
598                // Mirror execute_cal: destructive statements (PURGE, …) have
599                // no audited loop execution path, so they are invalid in a
600                // proposal — reject at validation, before review effort.
601                if classify(&parsed.statement) == StatementClass::Destructive {
602                    return Err(WErr::CalUnsupported(format!(
603                        "destructive CAL beyond FORGET <hash> is not executable through the loop substrate: {line}"
604                    )));
605                }
606            }
607        }
608    }
609    Ok(())
610}
611
612fn load_state(f: &AreevFacade) -> WResult<Value> {
613    let head = f
614        .with_store(|m| m.latest(LOOP_NS, STATE_SUBJECT, STATE_RELATION))
615        .map_err(we)?;
616    match head.as_ref().and_then(|g| g.get_str("object")) {
617        Some(json) => serde_json::from_str(json)
618            .map_err(|e| WErr::Substrate(format!("decode loop state: {e}"))),
619        None => Ok(Value::Null),
620    }
621}
622
623fn store_state(f: &AreevFacade, state: &Value) -> WResult<()> {
624    let json = serde_json::to_string(state)
625        .map_err(|e| WErr::Substrate(format!("encode loop state: {e}")))?;
626    let existing = f
627        .with_store(|m| m.latest(LOOP_NS, STATE_SUBJECT, STATE_RELATION))
628        .map_err(we)?;
629    f.with_store(|m| {
630        let mut fact = Fact::new(STATE_SUBJECT, STATE_RELATION, &json).namespace(LOOP_NS);
631        match &existing {
632            Some(g) => m.supersede(&g.hash, &mut fact).map(|_| ()),
633            None => m.add(&fact).map(|_| ()),
634        }
635    })
636    .map_err(we)
637}
638
639// --- pure helpers ---
640
641fn loop_relation(grain_type: &str) -> &'static str {
642    match grain_type {
643        "recommendation" => "loop_recommendation",
644        _ => "loop_audit", // the engine only puts recommendation + audit(observation) grains
645    }
646}
647
648fn loop_relation_type(relation: &str) -> Option<&'static str> {
649    match relation {
650        "loop_recommendation" => Some("recommendation"),
651        "loop_audit" => Some("observation"),
652        _ => None,
653    }
654}
655
656/// Deterministic, collision-safe subject so distinct loop grains never share
657/// a `(subject, relation)` head (which would look like a fork).
658fn unique_subject(payload: &str) -> String {
659    use std::hash::{Hash as _, Hasher};
660    let mut h = std::collections::hash_map::DefaultHasher::new();
661    payload.hash(&mut h);
662    format!("w{:016x}", h.finish())
663}
664
665fn grain_namespace(g: &DeserializedGrain) -> String {
666    g.get_str("namespace").unwrap_or("").to_string()
667}
668
669fn grain_created_ms(g: &DeserializedGrain) -> i64 {
670    g.get_i64("created_at")
671        .unwrap_or_else(|| g.header.created_at_sec as i64 * 1000)
672}
673
674fn map_user_grain(g: &DeserializedGrain, namespace: String, created_ms: i64) -> GrainRecord {
675    GrainRecord {
676        hash: g.hash.to_hex(),
677        grain_type: g.grain_type.as_str().to_string(),
678        namespace,
679        created_at_ms: created_ms,
680        valid_to_ms: g.get_i64("valid_to"),
681        superseded_by: None,
682        fields: g.fields.clone().into_iter().collect(),
683    }
684}
685
686fn loop_record(
687    hash: &str,
688    grain_type: &str,
689    created_ms: i64,
690    fields: Map<String, Value>,
691) -> GrainRecord {
692    let valid_to_ms = fields.get("valid_to_ms").and_then(Value::as_i64);
693    GrainRecord {
694        hash: hash.to_string(),
695        grain_type: grain_type.to_string(),
696        namespace: LOOP_NS.to_string(),
697        created_at_ms: created_ms,
698        valid_to_ms,
699        superseded_by: None,
700        fields,
701    }
702}
703
704/// Hashes that some sibling grain supersedes (via `derived_from`).
705fn superseded_set(grains: &[DeserializedGrain]) -> BTreeSet<String> {
706    let mut set = BTreeSet::new();
707    for g in grains {
708        for parent in derived_parents(g) {
709            set.insert(parent);
710        }
711    }
712    set
713}
714
715fn derived_parents(g: &DeserializedGrain) -> Vec<String> {
716    match g.fields.get("derived_from") {
717        Some(Value::String(s)) => vec![s.clone()],
718        Some(Value::Array(a)) => a
719            .iter()
720            .filter_map(|v| v.as_str().map(str::to_string))
721            .collect(),
722        _ => Vec::new(),
723    }
724}
725
726fn split_keyword(line: &str) -> (&str, &str) {
727    match line.split_once(char::is_whitespace) {
728        Some((k, rest)) => (k, rest.trim_start()),
729        None => (line, ""),
730    }
731}
732
733/// Parse `<type> {json}` → (type, fields).
734fn parse_type_and_json(s: &str) -> WResult<(String, Map<String, Value>)> {
735    let brace = s
736        .find('{')
737        .ok_or_else(|| WErr::CalUnsupported(format!("missing JSON object in {s:?}")))?;
738    let gtype = s[..brace].trim().to_string();
739    if gtype.is_empty() {
740        return Err(WErr::CalUnsupported(format!("missing grain type in {s:?}")));
741    }
742    let value: Value = serde_json::from_str(s[brace..].trim())
743        .map_err(|e| WErr::CalUnsupported(format!("bad JSON in {s:?}: {e}")))?;
744    match value {
745        Value::Object(m) => Ok((gtype, m)),
746        _ => Err(WErr::CalUnsupported(format!("JSON not an object in {s:?}"))),
747    }
748}
749
750fn collect_hashes(v: &Value, out: &mut Vec<String>) {
751    match v {
752        Value::Object(m) => {
753            for (k, val) in m {
754                if (k == "hash" || k == "new_hash") && val.is_string() {
755                    out.push(val.as_str().unwrap().to_string());
756                } else {
757                    collect_hashes(val, out);
758                }
759            }
760        }
761        Value::Array(a) => a.iter().for_each(|x| collect_hashes(x, out)),
762        _ => {}
763    }
764}