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 definition_inverse(&self, statement: &str) -> WResult<Option<String>> {
161                definition_inverse(self.facade_ref(), statement)
162            }
163            fn load_state(&self) -> WResult<Value> {
164                load_state(self.facade_ref())
165            }
166            fn store_state(&mut self, state: &Value) -> WResult<()> {
167                store_state(self.facade_ref(), state)
168            }
169            fn put_blob(&mut self, bytes: &[u8]) -> WResult<String> {
170                put_blob_op(self.facade_ref(), bytes)
171            }
172            fn get_blob(&mut self, address: &str) -> WResult<Vec<u8>> {
173                get_blob_op(self.facade_ref(), address)
174            }
175        }
176    };
177}
178
179impl_substrate!(AreevSubstrate);
180impl_substrate!(BorrowedSubstrate<'_>);
181
182// --- operations (free functions over &AreevFacade) ---
183
184fn recent_ns(f: &AreevFacade, ns: &str, gt: GrainType) -> WResult<Vec<DeserializedGrain>> {
185    // An explicitly requested namespace is a capability assertion. Deny it
186    // loudly; silently returning an empty set would misreport authorization
187    // failure as "the analyzer found no data".
188    require_read(f, ns)?;
189    f.with_store(|m| m.recent(ns, Some(gt), MAX_SCAN))
190        .map_err(we)
191}
192
193/// Enumerate every grain of `gt` across all namespaces via the op-log.
194fn all_grains(f: &AreevFacade, gt: GrainType) -> WResult<Vec<DeserializedGrain>> {
195    let ops = f.with_store(|m| m.changes_since(0, MAX_SCAN)).map_err(we)?;
196    let mut seen = BTreeSet::new();
197    let mut out = Vec::new();
198    for op in ops {
199        if op.op == OP_FORGET || !seen.insert(op.hash.to_hex()) {
200            continue;
201        }
202        if let Ok(g) = f.with_store(|m| m.get(&op.hash)) {
203            if g.grain_type == gt && may_read(f, &grain_namespace(&g)) {
204                out.push(g);
205            }
206        }
207    }
208    Ok(out)
209}
210
211fn read_user_type(
212    f: &AreevFacade,
213    gt: GrainType,
214    namespace: Option<&str>,
215    opts: ReadOpts,
216) -> WResult<Vec<GrainRecord>> {
217    let explicit = namespace.is_some();
218    let raw = match namespace {
219        Some(ns) => recent_ns(f, ns, gt)?,
220        None => all_grains(f, gt)?,
221    };
222    let superseded = superseded_set(&raw);
223    let mut out = Vec::new();
224    for g in raw {
225        let ns = grain_namespace(&g);
226        // Never surface loop-internal or governance grains in ALL-NAMESPACE
227        // scans. `agent:authz` holds the file's own grant Facts and the
228        // Tier-2 audit Observations; an analyzer sweeping everything that
229        // sees them as ordinary memory can propose tombstoning them
230        // (retention_sweep did), which locks every non-owner principal out
231        // of the file and destroys the very Art. 30 records the audit
232        // export exists to produce. Governance state is metadata about the
233        // memory, not memory. An EXPLICITLY requested namespace is
234        // different: the caller named it, `recent_ns` authz-checked it, and
235        // consumers like the run_outcome analyzer (agent:harness) and the
236        // gating-evidence loader depend on reading it.
237        if !explicit && (ns == LOOP_NS || ns.starts_with("agent:")) {
238            continue;
239        }
240        if opts.live_only && superseded.contains(&g.hash.to_hex()) {
241            continue;
242        }
243        let created = grain_created_ms(&g);
244        if opts.since_ms.is_some_and(|s| created < s) {
245            continue;
246        }
247        out.push(map_user_grain(&g, ns, created));
248    }
249    Ok(out)
250}
251
252/// Read loop-internal grains (recommendations / audit) stored as Facts.
253fn read_loop(
254    f: &AreevFacade,
255    relation: &str,
256    out_type: &str,
257    opts: ReadOpts,
258) -> WResult<Vec<GrainRecord>> {
259    let facts = recent_ns(f, LOOP_NS, GrainType::Fact)?;
260    let mut out = Vec::new();
261    for fact in facts {
262        if fact.get_str("relation") != Some(relation) {
263            continue;
264        }
265        let Some(payload) = fact.get_str("object") else {
266            continue;
267        };
268        let fields: Map<String, Value> = match serde_json::from_str(payload) {
269            Ok(Value::Object(m)) => m,
270            _ => continue,
271        };
272        let created = grain_created_ms(&fact);
273        if opts.since_ms.is_some_and(|s| created < s) {
274            continue;
275        }
276        out.push(loop_record(
277            &fact.hash.to_hex(),
278            out_type,
279            created,
280            fields,
281        ));
282    }
283    Ok(out)
284}
285
286fn caps(f: &AreevFacade) -> Capabilities {
287    Capabilities {
288        forks: true,
289        // On iff the host attached a telemetry sidecar (`aggregate`/`full`).
290        telemetry: f.with_store(|m| m.telemetry_mode()) != TelemetryMode::Off,
291        embeddings: f.with_store(|m| m.declared_embedding().is_some()),
292    }
293}
294
295/// Read the recall-telemetry sidecar rollups into the loop's substrate-agnostic
296/// [`TelemetryView`]. `None` when no sidecar is attached, so telemetry-fed
297/// analyzers degrade cleanly.
298fn telemetry(f: &AreevFacade, namespace: Option<&str>) -> WResult<Option<TelemetryView>> {
299    if f.with_store(|m| m.telemetry_mode()) == TelemetryMode::Off {
300        return Ok(None);
301    }
302    if let Some(ns) = namespace {
303        require_read(f, ns)?;
304    }
305    let access = f.with_store(|m| m.telemetry_access_stats(namespace)).map_err(we)?;
306    let queries = f.with_store(|m| m.telemetry_query_stats(namespace)).map_err(we)?;
307    // Budget telemetry has no namespace column. It is only safe to expose to
308    // a session that may read the whole memory; namespace-scoped sessions
309    // still receive their filtered access/query rollups below.
310    let budget = if may_read(f, "*") {
311        f.with_store(|m| m.telemetry_budget_stats()).map_err(we)?
312    } else {
313        Default::default()
314    };
315    Ok(Some(TelemetryView {
316        access: access
317            .into_iter()
318            .filter(|a| may_read(f, &a.ns))
319            .map(|a| GrainAccess { hash: a.hash, recall_count: a.recall_count, last_ms: a.last_ms })
320            .collect(),
321        queries: queries
322            .into_iter()
323            .filter(|q| may_read(f, &q.ns))
324            .map(|q| QueryUsage {
325                sample: q.sample,
326                run_count: q.run_count,
327                empty_count: q.empty_count,
328                sum_results: q.sum_results,
329                last_ms: q.last_ms,
330            })
331            .collect(),
332        budget: BudgetUsage {
333            sample_count: budget.sample_count,
334            overflow_count: budget.overflow_count,
335        },
336    }))
337}
338
339fn grains_of_type(
340    f: &AreevFacade,
341    grain_type: &str,
342    namespace: Option<&str>,
343    opts: ReadOpts,
344) -> WResult<Vec<GrainRecord>> {
345    match grain_type {
346        "recommendation" => read_loop(f, "loop_recommendation", "recommendation", opts),
347        "fact" => read_user_type(f, GrainType::Fact, namespace, opts),
348        "event" => read_user_type(f, GrainType::Event, namespace, opts),
349        "tool" => read_user_type(f, GrainType::Tool, namespace, opts),
350        // Loop audit records ride as Facts (mapping decision 1); asking for
351        // observations IN the loop namespace means those — the §7.4
352        // forensics chain (approver, BECAUSE, gating fields) reads here.
353        "observation" if namespace == Some(LOOP_NS) => {
354            read_loop(f, "loop_audit", "observation", opts)
355        }
356        "observation" => read_user_type(f, GrainType::Observation, namespace, opts),
357        "skill" => read_user_type(f, GrainType::Skill, namespace, opts),
358        "goal" => read_user_type(f, GrainType::Goal, namespace, opts),
359        other => Err(WErr::Substrate(format!("unsupported grain type {other:?}"))),
360    }
361}
362
363fn grain(f: &AreevFacade, hash: &str) -> WResult<Option<GrainRecord>> {
364    let h = Hash::from_hex(hash).map_err(we)?;
365    let g = match f.with_store(|m| m.get(&h)) {
366        Ok(g) => g,
367        Err(AreevError::NotFound(_)) => return Ok(None),
368        Err(e) => return Err(we(e)),
369    };
370    let created = grain_created_ms(&g);
371    // Supersession from the STORE'S index — `is_live()` decisions (the
372    // apply-gate's evalset-liveness check among them) must see the truth,
373    // not a hard-coded None. The review that found this: a superseded
374    // evalset read back as live would re-admit code its weakened
375    // replacement was supposed to force through re-gating.
376    let superseded_by = f
377        .with_store(|m| m.supersession_map(&[h]))
378        .map_err(we)?
379        .get(&h)
380        .map(|s| s.to_hex());
381    let ns = grain_namespace(&g);
382    // Reconstruct loop-internal grains from their JSON payload — but ONLY
383    // grains actually living in the loop namespace, and only for readers
384    // the loop namespace admits (a user Fact elsewhere that happens to use
385    // these relation strings is user data behind its own namespace's gate).
386    if ns == LOOP_NS {
387        if let Some(rel) = g.get_str("relation") {
388            if let Some(out_type) = loop_relation_type(rel) {
389                require_read(f, LOOP_NS)?;
390                if let Some(Value::Object(fields)) = g
391                    .get_str("object")
392                    .and_then(|p| serde_json::from_str(p).ok())
393                {
394                    let mut rec = loop_record(hash, out_type, created, fields);
395                    rec.superseded_by = superseded_by;
396                    return Ok(Some(rec));
397                }
398            }
399        }
400    }
401    require_read(f, &ns)?;
402    let mut rec = map_user_grain(&g, ns, created);
403    rec.superseded_by = superseded_by;
404    Ok(Some(rec))
405}
406
407fn heads(f: &AreevFacade, namespace: Option<&str>) -> WResult<Vec<HeadGroup>> {
408    let forks = f.with_store(|m| m.open_forks()).map_err(we)?;
409    Ok(forks
410        .into_iter()
411        .filter(|fg| fg.namespace != LOOP_NS)
412        .filter(|fg| may_read(f, &fg.namespace))
413        .filter(|fg| namespace.is_none_or(|ns| fg.namespace == ns))
414        .map(|fg| HeadGroup {
415            entity: format!("{}/{}", fg.subject, fg.relation),
416            heads: fg.heads.iter().map(|h| h.to_hex()).collect(),
417        })
418        .collect())
419}
420
421/// §7.4 blob seam: candidate code / evalset payloads ride the memory's own
422/// CAS (encrypted under the sidecar key when the memory is; addresses are
423/// plaintext digests — the documented oracle). Write-gated on the loop
424/// namespace: a session that cannot write loop grains cannot carry code.
425/// The cap on carried code, not on stored bytes: candidates above it belong
426/// in authored adds, not loop payloads.
427const MAX_LOOP_BLOB: usize = 4 * 1024 * 1024;
428
429fn put_blob_op(f: &AreevFacade, bytes: &[u8]) -> WResult<String> {
430    f.authz().check(Verb::Write, LOOP_NS).map_err(we)?;
431    if bytes.len() > MAX_LOOP_BLOB {
432        return Err(WErr::Substrate(format!(
433            "blob is {} bytes (loop cap {MAX_LOOP_BLOB})",
434            bytes.len()
435        )));
436    }
437    f.with_store(|m| m.put_blob(bytes)).map_err(we)
438}
439
440fn get_blob_op(f: &AreevFacade, address: &str) -> WResult<Vec<u8>> {
441    require_read(f, LOOP_NS)?;
442    f.with_store(|m| m.get_blob(address)).map_err(we)
443}
444
445fn put_grain(f: &AreevFacade, spec: &GrainSpec) -> WResult<String> {
446    // A fact spec that carries its OWN (subject, relation, object) — the
447    // engine's §7.4 code-promotion record — is stored as that triple, so
448    // hosts can query `(tool:X, mg:code_promotion)` to re-resolve. Wrapping
449    // it under a synthetic subject (the generic path below) would disguise
450    // the promotion as an unqueryable pseudo-audit row.
451    if spec.grain_type == "fact" {
452        if let (Some(subject), Some(relation), Some(object)) = (
453            spec.fields.get("subject").and_then(Value::as_str),
454            spec.fields.get("relation").and_then(Value::as_str),
455            spec.fields.get("object").and_then(Value::as_str),
456        ) {
457            let mut fact = Fact::new(subject, relation, object)
458                .namespace(LOOP_NS)
459                .confidence(1.0);
460            fact.common.created_at = spec_created_ms(spec);
461            for (k, v) in &spec.fields {
462                if !matches!(k.as_str(), "subject" | "relation" | "object") {
463                    fact.common.extra_fields.insert(k.clone(), v.clone());
464                }
465            }
466            return f.with_store(|m| m.add(&fact)).map(|h| h.to_hex()).map_err(we);
467        }
468    }
469    let payload = serde_json::to_string(&spec.fields)
470        .map_err(|e| WErr::Substrate(format!("encode grain: {e}")))?;
471    let mut fact = Fact::new(
472        &unique_subject(&payload),
473        loop_relation(&spec.grain_type),
474        &payload,
475    )
476    .namespace(LOOP_NS)
477    .confidence(1.0);
478    fact.common.created_at = spec_created_ms(spec);
479    f.with_store(|m| m.add(&fact))
480        .map(|h| h.to_hex())
481        .map_err(we)
482}
483
484fn supersede_op(f: &AreevFacade, target_hash: &str, spec: &GrainSpec) -> WResult<String> {
485    let payload = serde_json::to_string(&spec.fields)
486        .map_err(|e| WErr::Substrate(format!("encode grain: {e}")))?;
487    let old = Hash::from_hex(target_hash).map_err(we)?;
488    let mut fact = Fact::new(
489        &unique_subject(&payload),
490        loop_relation(&spec.grain_type),
491        &payload,
492    )
493    .namespace(LOOP_NS);
494    fact.common.created_at = spec_created_ms(spec);
495    f.with_store(|m| m.supersede(&old, &mut fact))
496        .map(|h| h.to_hex())
497        .map_err(we)
498}
499
500/// The engine-logical creation time already carried in the spec's fields —
501/// `created_at_ms` on recommendations, `at_ms` on audit observations. Stamping
502/// it onto the stored Fact keeps the grain's own `created_at` aligned with the
503/// engine's injected `now` instead of leaking the wall clock, so an areev-loop
504/// write is content-addressed purely from (spec, now). `None` (a spec without
505/// a time field) falls through to the store's own stamp.
506fn spec_created_ms(spec: &GrainSpec) -> Option<i64> {
507    spec.fields
508        .get("created_at_ms")
509        .or_else(|| spec.fields.get("at_ms"))
510        .and_then(Value::as_i64)
511}
512
513fn retract_op(f: &AreevFacade, hash: &str) -> WResult<()> {
514    // No index-only retraction primitive exists; the honest mapping for undoing
515    // an engine-created ADD is a tombstone of that grain.
516    let h = Hash::from_hex(hash).map_err(we)?;
517    f.with_store(|m| m.forget(&h)).map_err(we)
518}
519
520fn execute_cal(f: &AreevFacade, cal: &str) -> WResult<Vec<Value>> {
521    // Areev Loop proposals are a compact store-op form (ADD/SUPERSEDE/FORGET
522    // `<type> {json}`), applied via the facade's typed JSON methods — not the
523    // CAL text grammar (which uses SET/BECAUSE). Genuine CAL reads
524    // (metric/evidence queries) fall through to the real executor.
525    let mut rows = Vec::new();
526    for line in cal.lines() {
527        let line = line.trim();
528        if line.is_empty() {
529            continue;
530        }
531        let (keyword, rest) = split_keyword(line);
532        match keyword.to_ascii_uppercase().as_str() {
533            "FORGET" => {
534                let h = Hash::from_hex(rest.trim()).map_err(we)?;
535                // A loop-applied FORGET carries its reason on the audit
536                // chain already; stamp the Tier-2 audit with the source.
537                f.cal_delete(&h, Some("areev-loop apply")).map_err(we)?;
538            }
539            "ADD" => {
540                let (gtype, fields) = parse_type_and_json(rest)?;
541                let h = f.cal_add(&gtype, &fields).map_err(we)?;
542                rows.push(serde_json::json!({ "hash": h.to_hex() }));
543            }
544            "SUPERSEDE" => {
545                let (target, after) = rest
546                    .split_once(" WITH ")
547                    .ok_or_else(|| WErr::CalUnsupported(format!("malformed SUPERSEDE: {line}")))?;
548                let (gtype, fields) = parse_type_and_json(after)?;
549                let old = Hash::from_hex(target.trim()).map_err(we)?;
550                let h = f.cal_supersede(&old, &gtype, &fields).map_err(we)?;
551                rows.push(serde_json::json!({ "hash": h.to_hex() }));
552            }
553            _ => {
554                // Defense in depth: the engine's destructive gate (admin +
555                // allow_destructive) runs before apply, but this executor is
556                // process-permissive — never let it run destructive text.
557                // Loop-applied destruction goes through the special-cased,
558                // Tier-2-audited arms above (FORGET today), one statement
559                // shape at a time.
560                let parsed = parse(line).map_err(|e| WErr::CalUnsupported(e.to_string()))?;
561                if classify(&parsed.statement) == StatementClass::Destructive {
562                    return Err(WErr::CalUnsupported(format!(
563                        "destructive CAL beyond FORGET <hash> is not executable through the loop substrate: {line}"
564                    )));
565                }
566                let ex = CalExecutor::new(CalExecutorConfig::default());
567                let res = ex
568                    .execute(line, f)
569                    .map_err(|e| WErr::CalUnsupported(e.to_string()))?;
570                let payload = serde_json::to_value(&res.result)
571                    .map_err(|e| WErr::Substrate(format!("encode CAL result: {e}")))?;
572                let mut hs = Vec::new();
573                collect_hashes(&payload, &mut hs);
574                rows.extend(hs.into_iter().map(|h| serde_json::json!({ "hash": h })));
575            }
576        }
577    }
578    Ok(rows)
579}
580
581/// The CAL that restores the definition a `DEFINE QUERY` / `DEFINE TEMPLATE`
582/// is about to replace — the rollback inverse for a change that writes no
583/// grain (issue #28).
584///
585/// Reconstructed from the registry rather than remembered anywhere: the
586/// `qry:`/`tpl:` rows travel with the file, so the inverse is derived from the
587/// same state the apply is about to overwrite. When nothing is defined under
588/// that name the inverse is a `DROP`, which restores "no such definition" —
589/// the correct prior state, not a no-op.
590///
591/// A BUILT-IN definition has no inverse and returns `None`, which the engine
592/// turns into a refusal: built-ins are immutable, so a proposal to redefine
593/// one could not be rolled back and must not be applied.
594fn definition_inverse(f: &AreevFacade, statement: &str) -> WResult<Option<String>> {
595    let up = statement.trim_start().to_ascii_uppercase();
596    let (kind, after) = if let Some(rest) = up.strip_prefix("DEFINE QUERY") {
597        ("QUERY", &statement.trim_start()[statement.trim_start().len() - rest.len()..])
598    } else if let Some(rest) = up.strip_prefix("DEFINE TEMPLATE") {
599        ("TEMPLATE", &statement.trim_start()[statement.trim_start().len() - rest.len()..])
600    } else {
601        return Ok(None);
602    };
603    // The name is the first token, bare or quoted — both spellings parse.
604    let name = after
605        .trim_start()
606        .trim_start_matches('"')
607        .split(['"', '(', ' ', '\t'])
608        .next()
609        .unwrap_or("")
610        .trim()
611        .to_string();
612    if name.is_empty() {
613        return Ok(None);
614    }
615    if kind == "QUERY" {
616        match f.get_query(&name) {
617            // Built-ins are immutable; a rewrite of one is unapplicable.
618            Some(e) if e.builtin => Ok(None),
619            Some(e) => {
620                let params = if e.params.is_empty() {
621                    String::new()
622                } else {
623                    let list: Vec<String> =
624                        e.params.iter().map(|p| format!("${}", p.name)).collect();
625                    format!("({})", list.join(", "))
626                };
627                Ok(Some(format!(
628                    "DEFINE QUERY \"{name}\"{params} DESCRIPTION \"{}\" AS {{ {} }}",
629                    e.description.replace('"', "'"),
630                    e.body
631                )))
632            }
633            None => Ok(Some(format!("DROP QUERY \"{name}\""))),
634        }
635    } else {
636        match f.get_template(&name) {
637            Some(t) if t.builtin => Ok(None),
638            Some(t) => Ok(Some(format!(
639                "DEFINE TEMPLATE \"{name}\" DESCRIPTION \"{}\" AS {{ {} }}",
640                t.description.replace('"', "'"),
641                t.source
642            ))),
643            None => Ok(Some(format!("DROP TEMPLATE \"{name}\""))),
644        }
645    }
646}
647
648fn validate_cal(cal: &str) -> WResult<()> {
649    for line in cal.lines() {
650        let line = line.trim();
651        if line.is_empty() {
652            continue;
653        }
654        let (keyword, rest) = split_keyword(line);
655        match keyword.to_ascii_uppercase().as_str() {
656            "ADD" | "SUPERSEDE" => {}
657            // The loop's FORGET arm executes single-grain tombstones only —
658            // `FORGET SUBJECT "<id>"` must fail here, not at apply time.
659            "FORGET" => {
660                Hash::from_hex(rest.trim()).map_err(|_| {
661                    WErr::CalUnsupported(format!(
662                        "loop FORGET takes a single grain hash: {line}"
663                    ))
664                })?;
665            }
666            _ => {
667                let parsed = parse(line).map_err(|e| WErr::CalUnsupported(e.to_string()))?;
668                // Mirror execute_cal: destructive statements (PURGE, …) have
669                // no audited loop execution path, so they are invalid in a
670                // proposal — reject at validation, before review effort.
671                if classify(&parsed.statement) == StatementClass::Destructive {
672                    return Err(WErr::CalUnsupported(format!(
673                        "destructive CAL beyond FORGET <hash> is not executable through the loop substrate: {line}"
674                    )));
675                }
676            }
677        }
678    }
679    Ok(())
680}
681
682fn load_state(f: &AreevFacade) -> WResult<Value> {
683    let head = f
684        .with_store(|m| m.latest(LOOP_NS, STATE_SUBJECT, STATE_RELATION))
685        .map_err(we)?;
686    match head.as_ref().and_then(|g| g.get_str("object")) {
687        Some(json) => serde_json::from_str(json)
688            .map_err(|e| WErr::Substrate(format!("decode loop state: {e}"))),
689        None => Ok(Value::Null),
690    }
691}
692
693/// Persist the loop's state as a superseded Fact grain.
694///
695/// A grain, not a `meta` row, and the opposite of what `areev trigger` does with
696/// its `trg:` state — deliberately. Most of this blob is **governance**:
697/// `creators` and `co_creators` are what the self-approval block reads to refuse
698/// an approval by the principal who authored the recommendation, `audit_heads`
699/// chains the audit records, `status_index` carries the lifecycle. All of it has
700/// to travel with the file, or a replica finds no creator recorded and lets
701/// someone approve their own recommendation — a silent separation-of-duties
702/// bypass.
703///
704/// A trigger's state is a cursor and must NOT replicate, for the mirror-image
705/// reason. Do not harmonise the two; see `docs/loop.md`.
706fn store_state(f: &AreevFacade, state: &Value) -> WResult<()> {
707    let json = serde_json::to_string(state)
708        .map_err(|e| WErr::Substrate(format!("encode loop state: {e}")))?;
709    let existing = f
710        .with_store(|m| m.latest(LOOP_NS, STATE_SUBJECT, STATE_RELATION))
711        .map_err(we)?;
712    f.with_store(|m| {
713        let mut fact = Fact::new(STATE_SUBJECT, STATE_RELATION, &json).namespace(LOOP_NS);
714        match &existing {
715            Some(g) => m.supersede(&g.hash, &mut fact).map(|_| ()),
716            None => m.add(&fact).map(|_| ()),
717        }
718    })
719    .map_err(we)
720}
721
722// --- pure helpers ---
723
724fn loop_relation(grain_type: &str) -> &'static str {
725    match grain_type {
726        "recommendation" => "loop_recommendation",
727        _ => "loop_audit", // the engine only puts recommendation + audit(observation) grains
728    }
729}
730
731fn loop_relation_type(relation: &str) -> Option<&'static str> {
732    match relation {
733        "loop_recommendation" => Some("recommendation"),
734        "loop_audit" => Some("observation"),
735        _ => None,
736    }
737}
738
739/// Deterministic, collision-safe subject so distinct loop grains never share
740/// a `(subject, relation)` head (which would look like a fork).
741fn unique_subject(payload: &str) -> String {
742    use std::hash::{Hash as _, Hasher};
743    let mut h = std::collections::hash_map::DefaultHasher::new();
744    payload.hash(&mut h);
745    format!("w{:016x}", h.finish())
746}
747
748fn grain_namespace(g: &DeserializedGrain) -> String {
749    g.get_str("namespace").unwrap_or("").to_string()
750}
751
752fn grain_created_ms(g: &DeserializedGrain) -> i64 {
753    g.get_i64("created_at")
754        .unwrap_or_else(|| g.header.created_at_sec as i64 * 1000)
755}
756
757fn map_user_grain(g: &DeserializedGrain, namespace: String, created_ms: i64) -> GrainRecord {
758    GrainRecord {
759        hash: g.hash.to_hex(),
760        grain_type: g.grain_type.as_str().to_string(),
761        namespace,
762        created_at_ms: created_ms,
763        valid_to_ms: g.get_i64("valid_to"),
764        superseded_by: None,
765        fields: g.fields.clone().into_iter().collect(),
766    }
767}
768
769fn loop_record(
770    hash: &str,
771    grain_type: &str,
772    created_ms: i64,
773    fields: Map<String, Value>,
774) -> GrainRecord {
775    let valid_to_ms = fields.get("valid_to_ms").and_then(Value::as_i64);
776    GrainRecord {
777        hash: hash.to_string(),
778        grain_type: grain_type.to_string(),
779        namespace: LOOP_NS.to_string(),
780        created_at_ms: created_ms,
781        valid_to_ms,
782        superseded_by: None,
783        fields,
784    }
785}
786
787/// Hashes that some sibling grain supersedes (via `derived_from`).
788fn superseded_set(grains: &[DeserializedGrain]) -> BTreeSet<String> {
789    let mut set = BTreeSet::new();
790    for g in grains {
791        for parent in derived_parents(g) {
792            set.insert(parent);
793        }
794    }
795    set
796}
797
798fn derived_parents(g: &DeserializedGrain) -> Vec<String> {
799    match g.fields.get("derived_from") {
800        Some(Value::String(s)) => vec![s.clone()],
801        Some(Value::Array(a)) => a
802            .iter()
803            .filter_map(|v| v.as_str().map(str::to_string))
804            .collect(),
805        _ => Vec::new(),
806    }
807}
808
809fn split_keyword(line: &str) -> (&str, &str) {
810    match line.split_once(char::is_whitespace) {
811        Some((k, rest)) => (k, rest.trim_start()),
812        None => (line, ""),
813    }
814}
815
816/// Parse `<type> {json}` → (type, fields).
817fn parse_type_and_json(s: &str) -> WResult<(String, Map<String, Value>)> {
818    let brace = s
819        .find('{')
820        .ok_or_else(|| WErr::CalUnsupported(format!("missing JSON object in {s:?}")))?;
821    let gtype = s[..brace].trim().to_string();
822    if gtype.is_empty() {
823        return Err(WErr::CalUnsupported(format!("missing grain type in {s:?}")));
824    }
825    let value: Value = serde_json::from_str(s[brace..].trim())
826        .map_err(|e| WErr::CalUnsupported(format!("bad JSON in {s:?}: {e}")))?;
827    match value {
828        Value::Object(m) => Ok((gtype, m)),
829        _ => Err(WErr::CalUnsupported(format!("JSON not an object in {s:?}"))),
830    }
831}
832
833fn collect_hashes(v: &Value, out: &mut Vec<String>) {
834    match v {
835        Value::Object(m) => {
836            for (k, val) in m {
837                if (k == "hash" || k == "new_hash") && val.is_string() {
838                    out.push(val.as_str().unwrap().to_string());
839                } else {
840                    collect_hashes(val, out);
841                }
842            }
843        }
844        Value::Array(a) => a.iter().for_each(|x| collect_hashes(x, out)),
845        _ => {}
846    }
847}