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