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