areev_loop/substrate.rs
1//! The `OmsSubstrate` trait — the engine's only contact with a store. It is
2//! defined in terms of the OMS Level-2 protocol (CAL text ↔ JSON rows, grain
3//! get/put/supersede) plus curated typed reads the built-in analyzers use.
4//! Areev is the first substrate; the in-repo `ReferenceSubstrate` lets engine
5//! CI run with zero Areev, and doubles as the third-party conformance kit.
6
7use crate::error::Result;
8use crate::model::GrainRecord;
9use serde_json::{Map, Value};
10
11/// Optional substrate capabilities, declared once and matched against each
12/// analyzer manifest's `requires` list. A missing capability degrades an
13/// analyzer to an activation-ladder entry, never a silent no-op (§8).
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
15pub struct Capabilities {
16 /// Multiple concurrent heads per entity are tracked and queryable
17 /// (fork surfacing needs this).
18 pub forks: bool,
19 /// A telemetry sidecar records recall/access history.
20 pub telemetry: bool,
21 /// An embedder is installed (upgrades T0 analyzers to T1).
22 pub embeddings: bool,
23 /// Workflow plan grains can be structurally validated
24 /// ([`SubstrateRead::validate_plan`]). Without it a `plan_revision`
25 /// proposal stays advisory: the engine will not stamp an executable edit
26 /// to a plan it cannot have checked for reachability and cycle bounds.
27 pub plans: bool,
28 /// Executable tool code is modelled — the §7.4 blob seam plus
29 /// [`SubstrateRead::tool_evalset`]. Without it a `code_revision` proposal
30 /// stays advisory, because Rule E1's pin cannot be resolved.
31 pub code: bool,
32}
33
34/// Read filters for curated grain reads.
35#[derive(Debug, Clone, Copy)]
36pub struct ReadOpts {
37 /// When true (default), only live (non-superseded) grains are returned.
38 pub live_only: bool,
39 /// When set, only grains created at or after this epoch-ms are returned
40 /// (the incremental watermark scan, §8).
41 pub since_ms: Option<i64>,
42}
43
44impl Default for ReadOpts {
45 fn default() -> Self {
46 ReadOpts {
47 live_only: true,
48 since_ms: None,
49 }
50 }
51}
52
53/// A grain to be written by an apply. `derived_from` and other provenance go
54/// in `fields`; the substrate computes the content address.
55#[derive(Debug, Clone, PartialEq)]
56pub struct GrainSpec {
57 pub grain_type: String,
58 pub namespace: String,
59 pub fields: Map<String, Value>,
60}
61
62impl GrainSpec {
63 pub fn new(grain_type: impl Into<String>, namespace: impl Into<String>) -> Self {
64 GrainSpec {
65 grain_type: grain_type.into(),
66 namespace: namespace.into(),
67 fields: Map::new(),
68 }
69 }
70
71 pub fn with_field(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
72 self.fields.insert(key.into(), value.into());
73 self
74 }
75}
76
77/// One entity holding more than one live head (fork surfacing input).
78#[derive(Debug, Clone, PartialEq)]
79pub struct HeadGroup {
80 /// Entity identity, e.g. `"caller/john"` (namespace-qualified subject).
81 pub entity: String,
82 /// The competing head hashes.
83 pub heads: Vec<String>,
84}
85
86/// A snapshot of the recall-telemetry sidecar's rollups (§8). Telemetry-fed
87/// analyzers (`cold_grains`, `coverage_gap`, `budget_pressure`) read this via
88/// [`crate::analyzer::AnalyzeCtx::telemetry`]. It is an owned snapshot, not a
89/// live handle — analyzers stay read-only and can't reach the sidecar. A
90/// substrate without a sidecar returns `None`, so the analyzer degrades to an
91/// activation-ladder entry rather than firing on absent evidence.
92#[derive(Debug, Clone, Default, PartialEq)]
93pub struct TelemetryView {
94 /// Per-grain recall rollups. A grain **absent** here has never been
95 /// recalled (the `cold_grains` signal).
96 pub access: Vec<GrainAccess>,
97 /// Per-question rollups over free-text recalls (the `coverage_gap` signal).
98 pub queries: Vec<QueryUsage>,
99 /// Assembly-budget pressure rollup (the `budget_pressure` signal).
100 pub budget: BudgetUsage,
101}
102
103/// How often one grain has been surfaced by recall.
104#[derive(Debug, Clone, PartialEq)]
105pub struct GrainAccess {
106 pub hash: String,
107 pub recall_count: i64,
108 pub last_ms: i64,
109}
110
111/// How a recurring recall question has fared.
112#[derive(Debug, Clone, PartialEq)]
113pub struct QueryUsage {
114 /// A short human-readable sample of the query intent.
115 pub sample: String,
116 pub run_count: i64,
117 /// How many of those runs returned nothing — the coverage-gap signal.
118 pub empty_count: i64,
119 pub sum_results: i64,
120 pub last_ms: i64,
121}
122
123/// Assembly-budget pressure rollup.
124#[derive(Debug, Clone, Default, PartialEq)]
125pub struct BudgetUsage {
126 pub sample_count: i64,
127 pub overflow_count: i64,
128}
129
130/// The read-only slice of the substrate. Analyzers receive this (via
131/// `AnalyzeCtx`) and nothing else — the trust floor's "analyzers execute
132/// read-only" is enforced by the type system: a `&dyn SubstrateRead` cannot
133/// reach any mutating method. It is object-safe (no generics) so
134/// `builtin_analyzers()` can hand out `Box<dyn Analyzer>`.
135pub trait SubstrateRead {
136 /// Declared optional capabilities.
137 fn capabilities(&self) -> Capabilities;
138
139 /// Curated read: all grains of one OMS type, optionally namespace-scoped
140 /// and watermark/liveness filtered.
141 fn grains_of_type(
142 &self,
143 grain_type: &str,
144 namespace: Option<&str>,
145 opts: ReadOpts,
146 ) -> Result<Vec<GrainRecord>>;
147
148 /// Fetch one grain by content address.
149 fn grain(&self, hash: &str) -> Result<Option<GrainRecord>>;
150
151 /// Entities with more than one live head. Requires the `forks` capability;
152 /// the default impl reports it missing so non-fork substrates degrade
153 /// cleanly rather than pretend.
154 fn heads(&self, _namespace: Option<&str>) -> Result<Vec<HeadGroup>> {
155 Err(crate::error::Error::CapabilityMissing("forks".into()))
156 }
157
158 /// A snapshot of the recall-telemetry rollups (§8). Requires the
159 /// `telemetry` capability; the default returns `None` so substrates without
160 /// a sidecar degrade cleanly. `namespace` scopes the snapshot when set.
161 fn telemetry(&self, _namespace: Option<&str>) -> Result<Option<TelemetryView>> {
162 Ok(None)
163 }
164
165 /// Structurally validate a candidate Workflow grain body — the same
166 /// checks the runtime would run before executing it (unique and reachable
167 /// nodes, conditions parse, every cycle bounded). The engine calls this
168 /// before it will stamp a `plan_revision` as executable, so a proposal
169 /// that would produce an unrunnable plan never reaches a reviewer as
170 /// something they could apply.
171 ///
172 /// This is deliberately the substrate's job: the engine owns no plan
173 /// grammar, exactly as it owns no CAL grammar (see [`OmsSubstrate::validate_cal`]).
174 /// Requires the `plans` capability; the default reports it missing so a
175 /// substrate that does not model workflows degrades the proposal to
176 /// advisory rather than pretending to have checked it.
177 fn validate_plan(&self, _workflow: &Value) -> Result<()> {
178 Err(crate::error::Error::CapabilityMissing("plans".into()))
179 }
180
181 /// The evalset a code revision of `tool` must be gated against (Rule E1's
182 /// pin). Returns `Ok(None)` when the tool declares none, which makes a
183 /// `code_revision` for it advisory — an unpinnable revision is one no
184 /// gating run could ever satisfy.
185 ///
186 /// Resolved from the substrate and never from the model: a proposer that
187 /// could name its own grader is not gated.
188 fn tool_evalset(&self, _tool: &str) -> Result<Option<String>> {
189 Ok(None)
190 }
191
192 /// Embed one text through the substrate's installed embedder — the T1
193 /// leg of "is this the same instruction in different words". `Ok(None)`
194 /// when no embedder is installed (the `embeddings` capability is off),
195 /// so the caller falls back to a lexical measure rather than guessing.
196 /// The default reports none; substrates opt in.
197 fn embed(&self, _text: &str) -> Result<Option<Vec<f32>>> {
198 Ok(None)
199 }
200
201 /// The content address `put_grain(spec)` WOULD assign, computed without
202 /// writing — what lets a rehearsal name the exact grain a live pass
203 /// would have stored. `Ok(None)` when the substrate cannot say (the
204 /// default); a replay then reports findings by dedup key and summary.
205 fn address_of(&self, _spec: &GrainSpec) -> Result<Option<String>> {
206 Ok(None)
207 }
208
209 /// Rehearse a candidate Workflow body against the journaled runs of the
210 /// live plan at `incumbent_plan_hash` — re-driven through the runtime's
211 /// pure scheduler with every effect answered from the journal, nothing
212 /// dispatched, nothing written (`areev run shadow --plan-file`). The
213 /// report is the runtime's `ShadowPlanReport` as JSON; the engine reads
214 /// `totals.runs`, `no_worse`, `out_of_support_fraction` and the per-run
215 /// rows. `Ok(None)` when the substrate has no runtime or no journaled
216 /// runs of that plan — the proposal is then simply unrehearsed, never
217 /// refused for it. The default reports none.
218 fn plan_replay(&self, _incumbent_plan_hash: &str, _candidate: &Value) -> Result<Option<Value>> {
219 Ok(None)
220 }
221}
222
223/// The full store protocol the engine binds to: reads (via the supertrait)
224/// plus governed writes, CAL, and state persistence. All methods are fallible;
225/// a substrate fault surfaces as [`crate::error::Error::Substrate`].
226pub trait OmsSubstrate: SubstrateRead {
227 /// Append a new grain; returns its content address.
228 fn put_grain(&mut self, spec: &GrainSpec) -> Result<String>;
229
230 /// Supersede `target_hash` with a new grain carrying `justification`;
231 /// returns the new grain's address. Atomic and distinct from put
232 /// (OMS §28.4).
233 fn supersede(
234 &mut self,
235 target_hash: &str,
236 spec: &GrainSpec,
237 justification: &str,
238 ) -> Result<String>;
239
240 /// Index-layer retraction (`verification_status = retracted`) — the
241 /// inverse of an applied ADD, used by rollback. Not destructive (the grain
242 /// stays content-addressed; only the index marks it retracted). The
243 /// default reports it unsupported so substrates opt in.
244 fn retract(&mut self, hash: &str, reason: &str) -> Result<()> {
245 Err(crate::error::Error::Substrate(format!(
246 "retract not supported by this substrate ({hash}: {reason})"
247 )))
248 }
249
250 /// Store an opaque blob (candidate tool CODE, evalset payloads) in the
251 /// substrate's CAS, returning its address. §7.4's blob seam —
252 /// CAPABILITY-GATED: the default refuses, so a loop can only carry code
253 /// on substrates that explicitly opt in. Code enters the substrate only
254 /// through this seam or an authored add — never from a git mirror.
255 fn put_blob(&mut self, bytes: &[u8]) -> Result<String> {
256 let _ = bytes;
257 Err(crate::error::Error::Substrate(
258 "put_blob not supported by this substrate (code-carrying loops \
259 need an opted-in blob seam)"
260 .into(),
261 ))
262 }
263
264 /// Fetch a blob by the address `put_blob` returned. Same capability gate.
265 fn get_blob(&mut self, address: &str) -> Result<Vec<u8>> {
266 Err(crate::error::Error::Substrate(format!(
267 "get_blob not supported by this substrate ({address})"
268 )))
269 }
270
271 /// Execute CAL text, returning result rows as JSON. Used to regenerate
272 /// evidence sets (`evidence_query`) and to apply `proposal_cal`. A
273 /// substrate MAY reject CAL it cannot run with [`Error::CalUnsupported`].
274 ///
275 /// [`Error::CalUnsupported`]: crate::error::Error::CalUnsupported
276 fn execute_cal(&mut self, cal: &str) -> Result<Vec<Value>>;
277
278 /// The CAL that would restore the CURRENT definition named by a
279 /// `DEFINE QUERY` / `DEFINE TEMPLATE` statement — the rollback inverse,
280 /// captured before the definition is replaced.
281 ///
282 /// Returns `Ok(None)` when the substrate cannot produce one, which the
283 /// engine treats as "this definition rewrite is not applicable": a
284 /// definition change with no recorded inverse is one that `ROLLBACK`
285 /// would silently fail to undo, and a rollback that reports success
286 /// without restoring anything is worse than a refusal.
287 ///
288 /// The default implementation returns `None`, so a substrate that does
289 /// not model saved definitions simply cannot execute definition
290 /// rewrites — fail closed, no opt-out needed.
291 fn definition_inverse(&self, _statement: &str) -> Result<Option<String>> {
292 Ok(None)
293 }
294
295 /// Validate a CAL batch without executing it (statement classification,
296 /// destructive-op detection). Delegated to the substrate — the engine
297 /// contains a CAL *writer*, never a parser.
298 fn validate_cal(&self, cal: &str) -> Result<()>;
299
300 /// Load the persisted loop state blob (config + watermarks/cooldowns).
301 /// Returns `Value::Null` when nothing has been stored yet.
302 fn load_state(&self) -> Result<Value>;
303
304 /// Persist the loop state blob (a file-truth, so it travels with the
305 /// file on sync).
306 fn store_state(&mut self, state: &Value) -> Result<()>;
307}