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, Workflow, WorkflowEdge};
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
53/// A substrate error from this adapter's own checks (no underlying
54/// `AreevError` to wrap) — plan validation and pin resolution.
55fn werr(msg: &str) -> WErr {
56    WErr::Substrate(msg.to_string())
57}
58
59fn require_read(f: &AreevFacade, ns: &str) -> WResult<()> {
60    f.authz().check(Verb::Read, ns).map_err(we)
61}
62
63fn may_read(f: &AreevFacade, ns: &str) -> bool {
64    f.authz().allows(Verb::Read, ns)
65}
66
67/// An Areev-backed substrate that owns its facade (CLI path).
68pub struct AreevSubstrate {
69    facade: AreevFacade,
70}
71
72impl AreevSubstrate {
73    /// Wrap an open store, scoping the loop's own writes to `session_ns`
74    /// (defaults to the `areev-loop` namespace when `None`).
75    pub fn new(store: Areev, session_ns: Option<String>) -> Self {
76        let ns = session_ns.unwrap_or_else(|| LOOP_NS.to_string());
77        AreevSubstrate {
78            facade: AreevFacade::with_session(store, Some(ns), None),
79        }
80    }
81
82    /// Recover the underlying store (e.g. to reuse it for another verb).
83    pub fn into_store(self) -> Areev {
84        self.facade.into_inner()
85    }
86}
87
88/// An Areev-backed substrate that borrows a facade (bindings path). Construct
89/// one per areev-loop call over the host object's existing `&AreevFacade`.
90pub struct BorrowedSubstrate<'a> {
91    facade: &'a AreevFacade,
92}
93
94impl<'a> BorrowedSubstrate<'a> {
95    pub fn new(facade: &'a AreevFacade) -> Self {
96        BorrowedSubstrate { facade }
97    }
98}
99
100impl AreevSubstrate {
101    fn facade_ref(&self) -> &AreevFacade {
102        &self.facade
103    }
104
105    /// Store-level joins the loop vocabulary doesn't cover (the CLI's §7.4
106    /// blast-radius/provenance reports need `runs_touching`). Read-only use.
107    pub fn facade(&self) -> &AreevFacade {
108        &self.facade
109    }
110}
111
112impl BorrowedSubstrate<'_> {
113    fn facade_ref(&self) -> &AreevFacade {
114        self.facade
115    }
116}
117
118// The two wrappers implement the traits identically by delegating to the free
119// functions below (each operation borrows the facade only for its own call).
120macro_rules! impl_substrate {
121    ($ty:ty) => {
122        impl SubstrateRead for $ty {
123            fn capabilities(&self) -> Capabilities {
124                caps(self.facade_ref())
125            }
126            fn grains_of_type(
127                &self,
128                grain_type: &str,
129                namespace: Option<&str>,
130                opts: ReadOpts,
131            ) -> WResult<Vec<GrainRecord>> {
132                grains_of_type(self.facade_ref(), grain_type, namespace, opts)
133            }
134            fn grain(&self, hash: &str) -> WResult<Option<GrainRecord>> {
135                grain(self.facade_ref(), hash)
136            }
137            fn validate_plan(&self, workflow: &Value) -> WResult<()> {
138                validate_plan(workflow)
139            }
140            fn tool_evalset(&self, tool: &str) -> WResult<Option<String>> {
141                tool_evalset(self.facade_ref(), tool)
142            }
143            fn heads(&self, namespace: Option<&str>) -> WResult<Vec<HeadGroup>> {
144                heads(self.facade_ref(), namespace)
145            }
146            fn telemetry(&self, namespace: Option<&str>) -> WResult<Option<TelemetryView>> {
147                telemetry(self.facade_ref(), namespace)
148            }
149        }
150
151        impl OmsSubstrate for $ty {
152            fn put_grain(&mut self, spec: &GrainSpec) -> WResult<String> {
153                put_grain(self.facade_ref(), spec)
154            }
155            fn supersede(
156                &mut self,
157                target_hash: &str,
158                spec: &GrainSpec,
159                _j: &str,
160            ) -> WResult<String> {
161                supersede_op(self.facade_ref(), target_hash, spec)
162            }
163            fn retract(&mut self, hash: &str, _reason: &str) -> WResult<()> {
164                retract_op(self.facade_ref(), hash)
165            }
166            fn execute_cal(&mut self, cal: &str) -> WResult<Vec<Value>> {
167                execute_cal(self.facade_ref(), cal)
168            }
169            fn validate_cal(&self, cal: &str) -> WResult<()> {
170                validate_cal(cal)
171            }
172            fn definition_inverse(&self, statement: &str) -> WResult<Option<String>> {
173                definition_inverse(self.facade_ref(), statement)
174            }
175            fn load_state(&self) -> WResult<Value> {
176                load_state(self.facade_ref())
177            }
178            fn store_state(&mut self, state: &Value) -> WResult<()> {
179                store_state(self.facade_ref(), state)
180            }
181            fn put_blob(&mut self, bytes: &[u8]) -> WResult<String> {
182                put_blob_op(self.facade_ref(), bytes)
183            }
184            fn get_blob(&mut self, address: &str) -> WResult<Vec<u8>> {
185                get_blob_op(self.facade_ref(), address)
186            }
187        }
188    };
189}
190
191impl_substrate!(AreevSubstrate);
192impl_substrate!(BorrowedSubstrate<'_>);
193
194// --- operations (free functions over &AreevFacade) ---
195
196fn recent_ns(f: &AreevFacade, ns: &str, gt: GrainType) -> WResult<Vec<DeserializedGrain>> {
197    // An explicitly requested namespace is a capability assertion. Deny it
198    // loudly; silently returning an empty set would misreport authorization
199    // failure as "the analyzer found no data".
200    require_read(f, ns)?;
201    f.with_store(|m| m.recent(ns, Some(gt), MAX_SCAN))
202        .map_err(we)
203}
204
205/// Enumerate every grain of `gt` across all namespaces via the op-log.
206fn all_grains(f: &AreevFacade, gt: GrainType) -> WResult<Vec<DeserializedGrain>> {
207    let ops = f.with_store(|m| m.changes_since(0, MAX_SCAN)).map_err(we)?;
208    let mut seen = BTreeSet::new();
209    let mut out = Vec::new();
210    for op in ops {
211        if op.op == OP_FORGET || !seen.insert(op.hash.to_hex()) {
212            continue;
213        }
214        if let Ok(g) = f.with_store(|m| m.get(&op.hash)) {
215            if g.grain_type == gt && may_read(f, &grain_namespace(&g)) {
216                out.push(g);
217            }
218        }
219    }
220    Ok(out)
221}
222
223fn read_user_type(
224    f: &AreevFacade,
225    gt: GrainType,
226    namespace: Option<&str>,
227    opts: ReadOpts,
228) -> WResult<Vec<GrainRecord>> {
229    let explicit = namespace.is_some();
230    let raw = match namespace {
231        Some(ns) => recent_ns(f, ns, gt)?,
232        None => all_grains(f, gt)?,
233    };
234    let superseded = superseded_set(&raw);
235    let mut out = Vec::new();
236    for g in raw {
237        let ns = grain_namespace(&g);
238        // Never surface loop-internal or governance grains in ALL-NAMESPACE
239        // scans. `agent:authz` holds the file's own grant Facts and the
240        // Tier-2 audit Observations; an analyzer sweeping everything that
241        // sees them as ordinary memory can propose tombstoning them
242        // (retention_sweep did), which locks every non-owner principal out
243        // of the file and destroys the very Art. 30 records the audit
244        // export exists to produce. Governance state is metadata about the
245        // memory, not memory. An EXPLICITLY requested namespace is
246        // different: the caller named it, `recent_ns` authz-checked it, and
247        // consumers like the run_outcome analyzer (agent:harness) and the
248        // gating-evidence loader depend on reading it.
249        if !explicit && (ns == LOOP_NS || ns.starts_with("agent:")) {
250            continue;
251        }
252        if opts.live_only && superseded.contains(&g.hash.to_hex()) {
253            continue;
254        }
255        let created = grain_created_ms(&g);
256        if opts.since_ms.is_some_and(|s| created < s) {
257            continue;
258        }
259        out.push(map_user_grain(&g, ns, created));
260    }
261    Ok(out)
262}
263
264/// Read loop-internal grains (recommendations / audit) stored as Facts.
265fn read_loop(
266    f: &AreevFacade,
267    relation: &str,
268    out_type: &str,
269    opts: ReadOpts,
270) -> WResult<Vec<GrainRecord>> {
271    let facts = recent_ns(f, LOOP_NS, GrainType::Fact)?;
272    let mut out = Vec::new();
273    for fact in facts {
274        if fact.get_str("relation") != Some(relation) {
275            continue;
276        }
277        let Some(payload) = fact.get_str("object") else {
278            continue;
279        };
280        let fields: Map<String, Value> = match serde_json::from_str(payload) {
281            Ok(Value::Object(m)) => m,
282            _ => continue,
283        };
284        let created = grain_created_ms(&fact);
285        if opts.since_ms.is_some_and(|s| created < s) {
286            continue;
287        }
288        out.push(loop_record(
289            &fact.hash.to_hex(),
290            out_type,
291            created,
292            fields,
293        ));
294    }
295    Ok(out)
296}
297
298fn caps(f: &AreevFacade) -> Capabilities {
299    Capabilities {
300        forks: true,
301        // On iff the host attached a telemetry sidecar (`aggregate`/`full`).
302        telemetry: f.with_store(|m| m.telemetry_mode()) != TelemetryMode::Off,
303        embeddings: f.with_store(|m| m.declared_embedding().is_some()),
304        // Areev models both: workflow plans are grains this adapter can hand
305        // to the runtime's own validator, and tool code rides the CAS blob
306        // seam implemented above.
307        plans: true,
308        code: true,
309    }
310}
311
312/// Structurally validate a proposed Workflow body with the RUNTIME's own
313/// validator (`PlanGraph::build`: V1–V6 — unique and reachable nodes,
314/// conditions parse, Tarjan SCC with every cycle bounded). The loop therefore
315/// never carries a second, drifting opinion about what a runnable plan is.
316fn validate_plan(workflow: &Value) -> WResult<()> {
317    let fields = workflow
318        .as_object()
319        .ok_or_else(|| werr("a workflow body must be a JSON object"))?;
320    let wf = strict_workflow(fields)?;
321    areev_run_core::plan::PlanGraph::build(&wf)
322        .map(|_| ())
323        .map_err(|e| werr(&format!("proposed plan is not runnable: {e}")))
324}
325
326/// Read a `Workflow` out of a grain field map, STRICTLY.
327///
328/// Deliberately not [`areev_core::format::DeserializedGrain::to_workflow`],
329/// which is forward-compatible by contract — it skips a malformed edge rather
330/// than failing, which is right when reading a grain someone already wrote and
331/// wrong when checking one nobody has written yet. Here a malformed edge is
332/// the whole point of asking: silently dropping it would validate a plan that
333/// is not the plan under review.
334fn strict_workflow(fields: &Map<String, Value>) -> WResult<Workflow> {
335    let nodes: Vec<String> = match fields.get("nodes") {
336        Some(Value::Array(a)) => a
337            .iter()
338            .map(|v| {
339                v.as_str()
340                    .map(str::to_string)
341                    .ok_or_else(|| werr("every workflow node must be a string"))
342            })
343            .collect::<WResult<Vec<String>>>()?,
344        _ => return Err(werr("a workflow needs a 'nodes' array")),
345    };
346    let mut wf = Workflow::new(nodes);
347    if let Some(v) = fields.get("edges") {
348        let arr = v
349            .as_array()
350            .ok_or_else(|| werr("workflow 'edges' must be an array"))?;
351        for e in arr {
352            let (Some(src), Some(dst)) = (
353                e.get("src").and_then(Value::as_str),
354                e.get("dst").and_then(Value::as_str),
355            ) else {
356                return Err(werr("every workflow edge needs a 'src' and a 'dst'"));
357            };
358            let cond = match e.get("cond") {
359                None | Some(Value::Null) => None,
360                Some(Value::String(c)) => Some(c.clone()),
361                Some(_) => return Err(werr("an edge 'cond' must be a string")),
362            };
363            let max_cycles = match e.get("max_cycles") {
364                None | Some(Value::Null) => None,
365                Some(v) => Some(
366                    v.as_u64()
367                        .and_then(|n| u32::try_from(n).ok())
368                        .ok_or_else(|| werr("an edge 'max_cycles' must be a small integer"))?,
369                ),
370            };
371            wf.edges.push(WorkflowEdge {
372                src: src.to_string(),
373                dst: dst.to_string(),
374                cond,
375                max_cycles,
376            });
377        }
378    }
379    if let Some(v) = fields.get("bindings") {
380        let obj = v
381            .as_object()
382            .ok_or_else(|| werr("workflow 'bindings' must be an object"))?;
383        for (node, hash) in obj {
384            let h = hash
385                .as_str()
386                .ok_or_else(|| werr("a workflow binding must be a tool hash string"))?;
387            wf.bindings.insert(node.clone(), h.to_string());
388        }
389    }
390    if let Some(v) = fields.get("retries") {
391        let obj = v
392            .as_object()
393            .ok_or_else(|| werr("workflow 'retries' must be an object"))?;
394        for (node, max) in obj {
395            let m = max
396                .as_u64()
397                .and_then(|n| u32::try_from(n).ok())
398                .ok_or_else(|| werr("a workflow retry count must be a small integer"))?;
399            wf.retries.insert(node.clone(), m);
400        }
401    }
402    Ok(wf)
403}
404
405/// Rule E1's pin for a tool, read from the tool's own live DEFINITION grain
406/// (`evalset_hash`) — the same shape `areev tune` embeds in an adapter grain,
407/// so code and adapters resolve their gate the same way.
408///
409/// The tool DECLARES which evalset grades its revisions, ahead of any
410/// particular revision existing. That ordering is what the pin is for: a
411/// proposer that could choose its own grader is not gated.
412fn tool_evalset(f: &AreevFacade, tool: &str) -> WResult<Option<String>> {
413    let defs = grains_of_type(f, "tool", None, ReadOpts::default())?;
414    Ok(defs
415        .into_iter()
416        .filter(|g| {
417            g.str_field("kind") == Some("definition") && g.tool_name() == Some(tool)
418        })
419        .filter_map(|g| {
420            g.str_field("evalset_hash")
421                .filter(|h| !h.trim().is_empty())
422                .map(str::to_string)
423        })
424        .next())
425}
426
427/// Read the recall-telemetry sidecar rollups into the loop's substrate-agnostic
428/// [`TelemetryView`]. `None` when no sidecar is attached, so telemetry-fed
429/// analyzers degrade cleanly.
430fn telemetry(f: &AreevFacade, namespace: Option<&str>) -> WResult<Option<TelemetryView>> {
431    if f.with_store(|m| m.telemetry_mode()) == TelemetryMode::Off {
432        return Ok(None);
433    }
434    if let Some(ns) = namespace {
435        require_read(f, ns)?;
436    }
437    let access = f.with_store(|m| m.telemetry_access_stats(namespace)).map_err(we)?;
438    let queries = f.with_store(|m| m.telemetry_query_stats(namespace)).map_err(we)?;
439    // Budget telemetry has no namespace column. It is only safe to expose to
440    // a session that may read the whole memory; namespace-scoped sessions
441    // still receive their filtered access/query rollups below.
442    let budget = if may_read(f, "*") {
443        f.with_store(|m| m.telemetry_budget_stats()).map_err(we)?
444    } else {
445        Default::default()
446    };
447    Ok(Some(TelemetryView {
448        access: access
449            .into_iter()
450            .filter(|a| may_read(f, &a.ns))
451            .map(|a| GrainAccess { hash: a.hash, recall_count: a.recall_count, last_ms: a.last_ms })
452            .collect(),
453        queries: queries
454            .into_iter()
455            .filter(|q| may_read(f, &q.ns))
456            .map(|q| QueryUsage {
457                sample: q.sample,
458                run_count: q.run_count,
459                empty_count: q.empty_count,
460                sum_results: q.sum_results,
461                last_ms: q.last_ms,
462            })
463            .collect(),
464        budget: BudgetUsage {
465            sample_count: budget.sample_count,
466            overflow_count: budget.overflow_count,
467        },
468    }))
469}
470
471fn grains_of_type(
472    f: &AreevFacade,
473    grain_type: &str,
474    namespace: Option<&str>,
475    opts: ReadOpts,
476) -> WResult<Vec<GrainRecord>> {
477    match grain_type {
478        "recommendation" => read_loop(f, "loop_recommendation", "recommendation", opts),
479        "fact" => read_user_type(f, GrainType::Fact, namespace, opts),
480        "event" => read_user_type(f, GrainType::Event, namespace, opts),
481        "tool" => read_user_type(f, GrainType::Tool, namespace, opts),
482        // Loop audit records ride as Facts (mapping decision 1); asking for
483        // observations IN the loop namespace means those — the §7.4
484        // forensics chain (approver, BECAUSE, gating fields) reads here.
485        "observation" if namespace == Some(LOOP_NS) => {
486            read_loop(f, "loop_audit", "observation", opts)
487        }
488        "observation" => read_user_type(f, GrainType::Observation, namespace, opts),
489        "skill" => read_user_type(f, GrainType::Skill, namespace, opts),
490        "goal" => read_user_type(f, GrainType::Goal, namespace, opts),
491        other => Err(WErr::Substrate(format!("unsupported grain type {other:?}"))),
492    }
493}
494
495fn grain(f: &AreevFacade, hash: &str) -> WResult<Option<GrainRecord>> {
496    let h = Hash::from_hex(hash).map_err(we)?;
497    let g = match f.with_store(|m| m.get(&h)) {
498        Ok(g) => g,
499        Err(AreevError::NotFound(_)) => return Ok(None),
500        Err(e) => return Err(we(e)),
501    };
502    let created = grain_created_ms(&g);
503    // Supersession from the STORE'S index — `is_live()` decisions (the
504    // apply-gate's evalset-liveness check among them) must see the truth,
505    // not a hard-coded None. The review that found this: a superseded
506    // evalset read back as live would re-admit code its weakened
507    // replacement was supposed to force through re-gating.
508    let superseded_by = f
509        .with_store(|m| m.supersession_map(&[h]))
510        .map_err(we)?
511        .get(&h)
512        .map(|s| s.to_hex());
513    let ns = grain_namespace(&g);
514    // Reconstruct loop-internal grains from their JSON payload — but ONLY
515    // grains actually living in the loop namespace, and only for readers
516    // the loop namespace admits (a user Fact elsewhere that happens to use
517    // these relation strings is user data behind its own namespace's gate).
518    if ns == LOOP_NS {
519        if let Some(rel) = g.get_str("relation") {
520            if let Some(out_type) = loop_relation_type(rel) {
521                require_read(f, LOOP_NS)?;
522                if let Some(Value::Object(fields)) = g
523                    .get_str("object")
524                    .and_then(|p| serde_json::from_str(p).ok())
525                {
526                    let mut rec = loop_record(hash, out_type, created, fields);
527                    rec.superseded_by = superseded_by;
528                    return Ok(Some(rec));
529                }
530            }
531        }
532    }
533    require_read(f, &ns)?;
534    let mut rec = map_user_grain(&g, ns, created);
535    rec.superseded_by = superseded_by;
536    Ok(Some(rec))
537}
538
539fn heads(f: &AreevFacade, namespace: Option<&str>) -> WResult<Vec<HeadGroup>> {
540    let forks = f.with_store(|m| m.open_forks()).map_err(we)?;
541    Ok(forks
542        .into_iter()
543        .filter(|fg| fg.namespace != LOOP_NS)
544        .filter(|fg| may_read(f, &fg.namespace))
545        .filter(|fg| namespace.is_none_or(|ns| fg.namespace == ns))
546        .map(|fg| HeadGroup {
547            entity: format!("{}/{}", fg.subject, fg.relation),
548            heads: fg.heads.iter().map(|h| h.to_hex()).collect(),
549        })
550        .collect())
551}
552
553/// §7.4 blob seam: candidate code / evalset payloads ride the memory's own
554/// CAS (encrypted under the sidecar key when the memory is; addresses are
555/// plaintext digests — the documented oracle). Write-gated on the loop
556/// namespace: a session that cannot write loop grains cannot carry code.
557/// The cap on carried code, not on stored bytes: candidates above it belong
558/// in authored adds, not loop payloads.
559const MAX_LOOP_BLOB: usize = 4 * 1024 * 1024;
560
561fn put_blob_op(f: &AreevFacade, bytes: &[u8]) -> WResult<String> {
562    f.authz().check(Verb::Write, LOOP_NS).map_err(we)?;
563    if bytes.len() > MAX_LOOP_BLOB {
564        return Err(WErr::Substrate(format!(
565            "blob is {} bytes (loop cap {MAX_LOOP_BLOB})",
566            bytes.len()
567        )));
568    }
569    f.with_store(|m| m.put_blob(bytes)).map_err(we)
570}
571
572fn get_blob_op(f: &AreevFacade, address: &str) -> WResult<Vec<u8>> {
573    require_read(f, LOOP_NS)?;
574    f.with_store(|m| m.get_blob(address)).map_err(we)
575}
576
577fn put_grain(f: &AreevFacade, spec: &GrainSpec) -> WResult<String> {
578    // A fact spec that carries its OWN (subject, relation, object) — the
579    // engine's §7.4 code-promotion record — is stored as that triple, so
580    // hosts can query `(tool:X, mg:code_promotion)` to re-resolve. Wrapping
581    // it under a synthetic subject (the generic path below) would disguise
582    // the promotion as an unqueryable pseudo-audit row.
583    if spec.grain_type == "fact" {
584        if let (Some(subject), Some(relation), Some(object)) = (
585            spec.fields.get("subject").and_then(Value::as_str),
586            spec.fields.get("relation").and_then(Value::as_str),
587            spec.fields.get("object").and_then(Value::as_str),
588        ) {
589            let mut fact = Fact::new(subject, relation, object)
590                .namespace(LOOP_NS)
591                .confidence(1.0);
592            fact.common.created_at = spec_created_ms(spec);
593            for (k, v) in &spec.fields {
594                if !matches!(k.as_str(), "subject" | "relation" | "object") {
595                    fact.common.extra_fields.insert(k.clone(), v.clone());
596                }
597            }
598            return f.with_store(|m| m.add(&fact)).map(|h| h.to_hex()).map_err(we);
599        }
600    }
601    let payload = serde_json::to_string(&spec.fields)
602        .map_err(|e| WErr::Substrate(format!("encode grain: {e}")))?;
603    let mut fact = Fact::new(
604        &unique_subject(&payload),
605        loop_relation(&spec.grain_type),
606        &payload,
607    )
608    .namespace(LOOP_NS)
609    .confidence(1.0);
610    fact.common.created_at = spec_created_ms(spec);
611    f.with_store(|m| m.add(&fact))
612        .map(|h| h.to_hex())
613        .map_err(we)
614}
615
616fn supersede_op(f: &AreevFacade, target_hash: &str, spec: &GrainSpec) -> WResult<String> {
617    let payload = serde_json::to_string(&spec.fields)
618        .map_err(|e| WErr::Substrate(format!("encode grain: {e}")))?;
619    let old = Hash::from_hex(target_hash).map_err(we)?;
620    let mut fact = Fact::new(
621        &unique_subject(&payload),
622        loop_relation(&spec.grain_type),
623        &payload,
624    )
625    .namespace(LOOP_NS);
626    fact.common.created_at = spec_created_ms(spec);
627    f.with_store(|m| m.supersede(&old, &mut fact))
628        .map(|h| h.to_hex())
629        .map_err(we)
630}
631
632/// The engine-logical creation time already carried in the spec's fields —
633/// `created_at_ms` on recommendations, `at_ms` on audit observations. Stamping
634/// it onto the stored Fact keeps the grain's own `created_at` aligned with the
635/// engine's injected `now` instead of leaking the wall clock, so an areev-loop
636/// write is content-addressed purely from (spec, now). `None` (a spec without
637/// a time field) falls through to the store's own stamp.
638fn spec_created_ms(spec: &GrainSpec) -> Option<i64> {
639    spec.fields
640        .get("created_at_ms")
641        .or_else(|| spec.fields.get("at_ms"))
642        .and_then(Value::as_i64)
643}
644
645fn retract_op(f: &AreevFacade, hash: &str) -> WResult<()> {
646    // No index-only retraction primitive exists; the honest mapping for undoing
647    // an engine-created ADD is a tombstone of that grain.
648    let h = Hash::from_hex(hash).map_err(we)?;
649    f.with_store(|m| m.forget(&h)).map_err(we)
650}
651
652fn execute_cal(f: &AreevFacade, cal: &str) -> WResult<Vec<Value>> {
653    // Areev Loop proposals are a compact store-op form (ADD/SUPERSEDE/FORGET
654    // `<type> {json}`), applied via the facade's typed JSON methods — not the
655    // CAL text grammar (which uses SET/BECAUSE). Genuine CAL reads
656    // (metric/evidence queries) fall through to the real executor.
657    let mut rows = Vec::new();
658    for line in cal.lines() {
659        let line = line.trim();
660        if line.is_empty() {
661            continue;
662        }
663        let (keyword, rest) = split_keyword(line);
664        match keyword.to_ascii_uppercase().as_str() {
665            "FORGET" => {
666                let h = Hash::from_hex(rest.trim()).map_err(we)?;
667                // A loop-applied FORGET carries its reason on the audit
668                // chain already; stamp the Tier-2 audit with the source.
669                f.cal_delete(&h, Some("areev-loop apply")).map_err(we)?;
670            }
671            "ADD" => {
672                let (gtype, fields) = parse_type_and_json(rest)?;
673                let h = f.cal_add(&gtype, &fields).map_err(we)?;
674                rows.push(serde_json::json!({ "hash": h.to_hex() }));
675            }
676            "SUPERSEDE" => {
677                let (target, after) = rest
678                    .split_once(" WITH ")
679                    .ok_or_else(|| WErr::CalUnsupported(format!("malformed SUPERSEDE: {line}")))?;
680                let (gtype, fields) = parse_type_and_json(after)?;
681                let old = Hash::from_hex(target.trim()).map_err(we)?;
682                let h = f.cal_supersede(&old, &gtype, &fields).map_err(we)?;
683                rows.push(serde_json::json!({ "hash": h.to_hex() }));
684            }
685            _ => {
686                // Defense in depth: the engine's destructive gate (admin +
687                // allow_destructive) runs before apply, but this executor is
688                // process-permissive — never let it run destructive text.
689                // Loop-applied destruction goes through the special-cased,
690                // Tier-2-audited arms above (FORGET today), one statement
691                // shape at a time.
692                let parsed = parse(line).map_err(|e| WErr::CalUnsupported(e.to_string()))?;
693                if classify(&parsed.statement) == StatementClass::Destructive {
694                    return Err(WErr::CalUnsupported(format!(
695                        "destructive CAL beyond FORGET <hash> is not executable through the loop substrate: {line}"
696                    )));
697                }
698                let ex = CalExecutor::new(CalExecutorConfig::default());
699                let res = ex
700                    .execute(line, f)
701                    .map_err(|e| WErr::CalUnsupported(e.to_string()))?;
702                let payload = serde_json::to_value(&res.result)
703                    .map_err(|e| WErr::Substrate(format!("encode CAL result: {e}")))?;
704                let mut hs = Vec::new();
705                collect_hashes(&payload, &mut hs);
706                rows.extend(hs.into_iter().map(|h| serde_json::json!({ "hash": h })));
707            }
708        }
709    }
710    Ok(rows)
711}
712
713/// The CAL that restores the definition a `DEFINE QUERY` / `DEFINE TEMPLATE`
714/// is about to replace — the rollback inverse for a change that writes no
715/// grain (issue #28).
716///
717/// Reconstructed from the registry rather than remembered anywhere: the
718/// `qry:`/`tpl:` rows travel with the file, so the inverse is derived from the
719/// same state the apply is about to overwrite. When nothing is defined under
720/// that name the inverse is a `DROP`, which restores "no such definition" —
721/// the correct prior state, not a no-op.
722///
723/// A BUILT-IN definition has no inverse and returns `None`, which the engine
724/// turns into a refusal: built-ins are immutable, so a proposal to redefine
725/// one could not be rolled back and must not be applied.
726fn definition_inverse(f: &AreevFacade, statement: &str) -> WResult<Option<String>> {
727    let up = statement.trim_start().to_ascii_uppercase();
728    let (kind, after) = if let Some(rest) = up.strip_prefix("DEFINE QUERY") {
729        ("QUERY", &statement.trim_start()[statement.trim_start().len() - rest.len()..])
730    } else if let Some(rest) = up.strip_prefix("DEFINE TEMPLATE") {
731        ("TEMPLATE", &statement.trim_start()[statement.trim_start().len() - rest.len()..])
732    } else {
733        return Ok(None);
734    };
735    // The name is the first token, bare or quoted — both spellings parse.
736    let name = after
737        .trim_start()
738        .trim_start_matches('"')
739        .split(['"', '(', ' ', '\t'])
740        .next()
741        .unwrap_or("")
742        .trim()
743        .to_string();
744    if name.is_empty() {
745        return Ok(None);
746    }
747    if kind == "QUERY" {
748        match f.get_query(&name) {
749            // Built-ins are immutable; a rewrite of one is unapplicable.
750            Some(e) if e.builtin => Ok(None),
751            Some(e) => {
752                let params = if e.params.is_empty() {
753                    String::new()
754                } else {
755                    let list: Vec<String> =
756                        e.params.iter().map(|p| format!("${}", p.name)).collect();
757                    format!("({})", list.join(", "))
758                };
759                Ok(Some(format!(
760                    "DEFINE QUERY \"{name}\"{params} DESCRIPTION \"{}\" AS {{ {} }}",
761                    e.description.replace('"', "'"),
762                    e.body
763                )))
764            }
765            None => Ok(Some(format!("DROP QUERY \"{name}\""))),
766        }
767    } else {
768        match f.get_template(&name) {
769            Some(t) if t.builtin => Ok(None),
770            Some(t) => Ok(Some(format!(
771                "DEFINE TEMPLATE \"{name}\" DESCRIPTION \"{}\" AS {{ {} }}",
772                t.description.replace('"', "'"),
773                t.source
774            ))),
775            None => Ok(Some(format!("DROP TEMPLATE \"{name}\""))),
776        }
777    }
778}
779
780fn validate_cal(cal: &str) -> WResult<()> {
781    for line in cal.lines() {
782        let line = line.trim();
783        if line.is_empty() {
784            continue;
785        }
786        let (keyword, rest) = split_keyword(line);
787        match keyword.to_ascii_uppercase().as_str() {
788            "ADD" | "SUPERSEDE" => {}
789            // The loop's FORGET arm executes single-grain tombstones only —
790            // `FORGET SUBJECT "<id>"` must fail here, not at apply time.
791            "FORGET" => {
792                Hash::from_hex(rest.trim()).map_err(|_| {
793                    WErr::CalUnsupported(format!(
794                        "loop FORGET takes a single grain hash: {line}"
795                    ))
796                })?;
797            }
798            _ => {
799                let parsed = parse(line).map_err(|e| WErr::CalUnsupported(e.to_string()))?;
800                // Mirror execute_cal: destructive statements (PURGE, …) have
801                // no audited loop execution path, so they are invalid in a
802                // proposal — reject at validation, before review effort.
803                if classify(&parsed.statement) == StatementClass::Destructive {
804                    return Err(WErr::CalUnsupported(format!(
805                        "destructive CAL beyond FORGET <hash> is not executable through the loop substrate: {line}"
806                    )));
807                }
808            }
809        }
810    }
811    Ok(())
812}
813
814fn load_state(f: &AreevFacade) -> WResult<Value> {
815    let head = f
816        .with_store(|m| m.latest(LOOP_NS, STATE_SUBJECT, STATE_RELATION))
817        .map_err(we)?;
818    match head.as_ref().and_then(|g| g.get_str("object")) {
819        Some(json) => serde_json::from_str(json)
820            .map_err(|e| WErr::Substrate(format!("decode loop state: {e}"))),
821        None => Ok(Value::Null),
822    }
823}
824
825/// Persist the loop's state as a superseded Fact grain.
826///
827/// A grain, not a `meta` row, and the opposite of what `areev trigger` does with
828/// its `trg:` state — deliberately. Most of this blob is **governance**:
829/// `creators` and `co_creators` are what the self-approval block reads to refuse
830/// an approval by the principal who authored the recommendation, `audit_heads`
831/// chains the audit records, `status_index` carries the lifecycle. All of it has
832/// to travel with the file, or a replica finds no creator recorded and lets
833/// someone approve their own recommendation — a silent separation-of-duties
834/// bypass.
835///
836/// A trigger's state is a cursor and must NOT replicate, for the mirror-image
837/// reason. Do not harmonise the two; see `docs/loop.md`.
838fn store_state(f: &AreevFacade, state: &Value) -> WResult<()> {
839    let json = serde_json::to_string(state)
840        .map_err(|e| WErr::Substrate(format!("encode loop state: {e}")))?;
841    let existing = f
842        .with_store(|m| m.latest(LOOP_NS, STATE_SUBJECT, STATE_RELATION))
843        .map_err(we)?;
844    f.with_store(|m| {
845        let mut fact = Fact::new(STATE_SUBJECT, STATE_RELATION, &json).namespace(LOOP_NS);
846        match &existing {
847            Some(g) => m.supersede(&g.hash, &mut fact).map(|_| ()),
848            None => m.add(&fact).map(|_| ()),
849        }
850    })
851    .map_err(we)
852}
853
854// --- pure helpers ---
855
856fn loop_relation(grain_type: &str) -> &'static str {
857    match grain_type {
858        "recommendation" => "loop_recommendation",
859        _ => "loop_audit", // the engine only puts recommendation + audit(observation) grains
860    }
861}
862
863fn loop_relation_type(relation: &str) -> Option<&'static str> {
864    match relation {
865        "loop_recommendation" => Some("recommendation"),
866        "loop_audit" => Some("observation"),
867        _ => None,
868    }
869}
870
871/// Deterministic, collision-safe subject so distinct loop grains never share
872/// a `(subject, relation)` head (which would look like a fork).
873fn unique_subject(payload: &str) -> String {
874    use std::hash::{Hash as _, Hasher};
875    let mut h = std::collections::hash_map::DefaultHasher::new();
876    payload.hash(&mut h);
877    format!("w{:016x}", h.finish())
878}
879
880fn grain_namespace(g: &DeserializedGrain) -> String {
881    g.get_str("namespace").unwrap_or("").to_string()
882}
883
884fn grain_created_ms(g: &DeserializedGrain) -> i64 {
885    g.get_i64("created_at")
886        .unwrap_or_else(|| g.header.created_at_sec as i64 * 1000)
887}
888
889fn map_user_grain(g: &DeserializedGrain, namespace: String, created_ms: i64) -> GrainRecord {
890    GrainRecord {
891        hash: g.hash.to_hex(),
892        grain_type: g.grain_type.as_str().to_string(),
893        namespace,
894        created_at_ms: created_ms,
895        valid_to_ms: g.get_i64("valid_to"),
896        superseded_by: None,
897        fields: g.fields.clone().into_iter().collect(),
898    }
899}
900
901fn loop_record(
902    hash: &str,
903    grain_type: &str,
904    created_ms: i64,
905    fields: Map<String, Value>,
906) -> GrainRecord {
907    let valid_to_ms = fields.get("valid_to_ms").and_then(Value::as_i64);
908    GrainRecord {
909        hash: hash.to_string(),
910        grain_type: grain_type.to_string(),
911        namespace: LOOP_NS.to_string(),
912        created_at_ms: created_ms,
913        valid_to_ms,
914        superseded_by: None,
915        fields,
916    }
917}
918
919/// Hashes that some sibling grain supersedes (via `derived_from`).
920fn superseded_set(grains: &[DeserializedGrain]) -> BTreeSet<String> {
921    let mut set = BTreeSet::new();
922    for g in grains {
923        for parent in derived_parents(g) {
924            set.insert(parent);
925        }
926    }
927    set
928}
929
930fn derived_parents(g: &DeserializedGrain) -> Vec<String> {
931    match g.fields.get("derived_from") {
932        Some(Value::String(s)) => vec![s.clone()],
933        Some(Value::Array(a)) => a
934            .iter()
935            .filter_map(|v| v.as_str().map(str::to_string))
936            .collect(),
937        _ => Vec::new(),
938    }
939}
940
941fn split_keyword(line: &str) -> (&str, &str) {
942    match line.split_once(char::is_whitespace) {
943        Some((k, rest)) => (k, rest.trim_start()),
944        None => (line, ""),
945    }
946}
947
948/// Parse `<type> {json}` → (type, fields).
949fn parse_type_and_json(s: &str) -> WResult<(String, Map<String, Value>)> {
950    let brace = s
951        .find('{')
952        .ok_or_else(|| WErr::CalUnsupported(format!("missing JSON object in {s:?}")))?;
953    let gtype = s[..brace].trim().to_string();
954    if gtype.is_empty() {
955        return Err(WErr::CalUnsupported(format!("missing grain type in {s:?}")));
956    }
957    let value: Value = serde_json::from_str(s[brace..].trim())
958        .map_err(|e| WErr::CalUnsupported(format!("bad JSON in {s:?}: {e}")))?;
959    match value {
960        Value::Object(m) => Ok((gtype, m)),
961        _ => Err(WErr::CalUnsupported(format!("JSON not an object in {s:?}"))),
962    }
963}
964
965fn collect_hashes(v: &Value, out: &mut Vec<String>) {
966    match v {
967        Value::Object(m) => {
968            for (k, val) in m {
969                if (k == "hash" || k == "new_hash") && val.is_string() {
970                    out.push(val.as_str().unwrap().to_string());
971                } else {
972                    collect_hashes(val, out);
973                }
974            }
975        }
976        Value::Array(a) => a.iter().for_each(|x| collect_hashes(x, out)),
977        _ => {}
978    }
979}